Repository: dfinity/canbench
Pull request #110: feat: add results table to the summary
Author: login: maksymar
Created: 2025-05-13T13:44:19Z | Merged: 2025-05-14T15:18:51Z

This PR adds significant results table after the short summary and improves formatting consistency

- Introduces a significant results table after the short summary
- Unifies value formatting across CSV, short summary, and results table (consistent units and signed changes)
- Sorts results by benchmark name, status, and descending instructions change, highlighting major regressions at the top

Summary example:
```
---------------------------------------------------
Summary:
  instructions:
    status:   Regressions detected! 🔴
    counts:   [total 1 | new 0 | improved 0 | regressed 1 | unchanged 0]
    change:   [min +4.14K | med +4.14K | max +4.14K]
    change %: [min +inf% | med +inf% | max +inf%]

  heap_increase:
    status:   No significant changes detected 👍
    counts:   [total 1 | new 0 | improved 0 | regressed 0 | unchanged 1]
    change:   [min 0 | med 0 | max 0]
    change %: [min 0.00% | med 0.00% | max 0.00%]

  stable_memory_increase:
    status:   No significant changes detected 👍
    counts:   [total 1 | new 0 | improved 0 | regressed 0 | unchanged 1]
    change:   [min 0 | med 0 | max 0]
    change %: [min 0.00% | med 0.00% | max 0.00%]

---------------------------------------------------

Only significant changes:
| status | name                        |   ins |  ins Δ% | HI |    HI Δ% | SMI |  SMI Δ% |
|--------|-----------------------------|-------|---------|----|----------|-----|---------|
|   +    | bench_scope_exists          | 4.14K |   +inf% |  0 |    0.00% |   0 |   0.00% |
|  +/-   | bench_scope_exists::scope_1 | 1.05K | +31.25% |  0 | -100.00% |   0 |   0.00% |
|  new   | bench_scope_exists::scope_2 |   786 |         |  0 |          |   0 |         |

---------------------------------------------------
```


Diff:
diff --git a/canbench-bin/src/csv_file.rs b/canbench-bin/src/csv_file.rs
index 76442f40..5d8ed146 100644
--- a/canbench-bin/src/csv_file.rs
+++ b/canbench-bin/src/csv_file.rs
@@ -1,5 +1,5 @@
-use canbench_rs::{BenchResult, Measurement};
-use std::{collections::BTreeMap, fs::File, io::Write, path::PathBuf};
+use crate::data::Entry;
+use std::{fs::File, io::Write, path::PathBuf};
 
 /// Delimiter used in the CSV file.
 /// Use `,` for GitHub/VSCode preview.
@@ -7,15 +7,11 @@ use std::{collections::BTreeMap, fs::File, io::Write, path::PathBuf};
 const DELIMITER: char = ',';
 
 /// Write benchmark results to a CSV file.
-pub(crate) fn write(
-    output_file: &PathBuf,
-    new_results: &BTreeMap<String, BenchResult>,
-    old_results: &BTreeMap<String, BenchResult>,
-) {
+pub(crate) fn write(output_file: &PathBuf, data: &[Entry]) {
     let mut file = File::create(output_file)
         .unwrap_or_else(|e| panic!("Failed to create results file {:?}: {}", output_file, e));
 
-    let headers = [
+    const HEADERS: &[&str] = &[
         "status",
         "name",
         "instructions",
@@ -26,66 +22,22 @@ pub(crate) fn write(
         "stable_memory_increase change %",
     ];
 
-    writeln!(file, "{}", headers.join(&DELIMITER.to_string())).expect("Failed to write CSV header");
-
-    for (name, new_bench) in new_results {
-        // Process total measurements.
-        let old_bench = old_results.get(name);
-        let old = old_bench.map(|b| &b.total);
-        let status = if old.is_some() { "" } else { "new" };
-        write_measurement_diff(&mut file, status, name, &new_bench.total, old);
-
-        // Process scope measurements.
-        for (scope, new) in new_bench.scopes.iter() {
-            let old = old_bench.and_then(|b| b.scopes.get(scope));
-            let status = if old.is_some() { "" } else { "new" };
-            let full_name = format!("{}::{}", name, scope);
-            write_measurement_diff(&mut file, status, &full_name, new, old);
-        }
+    writeln!(file, "{}", HEADERS.join(&DELIMITER.to_string())).expect("Failed to write CSV header");
+
+    for entry in data {
+        let name = entry.benchmark.full_name();
+        let row = [
+            entry.status.clone(),
+            name.clone(),
+            entry.instructions.fmt_current(), // CSV report use full numbers
+            entry.instructions.fmt_human_percent(),
+            entry.heap_increase.fmt_current(),
+            entry.heap_increase.fmt_human_percent(),
+            entry.stable_memory_increase.fmt_current(),
+            entry.stable_memory_increase.fmt_human_percent(),
+        ];
+
+        writeln!(file, "{}", row.join(&DELIMITER.to_string()))
+            .unwrap_or_else(|e| panic!("Failed to write row for {}: {}", name, e));
     }
 }
-
-fn write_measurement_diff(
-    file: &mut File,
-    status: &str,
-    name: &str,
-    new: &Measurement,
-    old: Option<&Measurement>,
-) {
-    let format_number = |n: u64| n.to_string();
-    let format_percent = |new, old| {
-        let delta = new as f64 - old as f64;
-        if old == 0 {
-            match delta {
-                d if d < 0.0 => String::from("-inf%"),
-                d if d > 0.0 => String::from("+inf%"),
-                _ => String::from("0.00%"),
-            }
-        } else {
-            format!("{:.2}%", delta / old as f64 * 100.0)
-        }
-    };
-
-    let (instructions_p, heap_increase_p, stable_memory_increase_p) = match old {
-        Some(old) => (
-            format_percent(new.instructions, old.instructions),
-            format_percent(new.heap_increase, old.heap_increase),
-            format_percent(new.stable_memory_increase, old.stable_memory_increase),
-        ),
-        None => (String::new(), String::new(), String::new()),
-    };
-
-    let row = [
-        status,
-        name,
-        &format_number(new.instructions),
-        &instructions_p,
-        &format_number(new.heap_increase),
-        &heap_increase_p,
-        &format_number(new.stable_memory_increase),
-        &stable_memory_increase_p,
-    ];
-
-    writeln!(file, "{}", row.join(&DELIMITER.to_string()))
-        .unwrap_or_else(|e| panic!("Failed to write row for {}: {}", name, e));
-}
diff --git a/canbench-bin/src/data.rs b/canbench-bin/src/data.rs
new file mode 100644
index 00000000..b91a7640
--- /dev/null
+++ b/canbench-bin/src/data.rs
@@ -0,0 +1,165 @@
+use crate::fmt::{fmt_human_percent, fmt_human_u64};
+use canbench_rs::{BenchResult, Measurement};
+use std::collections::BTreeMap;
+
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
+pub(crate) struct Entry {
+    pub(crate) status: String,
+    pub(crate) benchmark: Benchmark,
+    pub(crate) instructions: Values,
+    pub(crate) heap_increase: Values,
+    pub(crate) stable_memory_increase: Values,
+}
+
+impl Entry {
+    pub(crate) fn has_scope(&self) -> bool {
+        self.benchmark.scope.is_some()
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
+pub(crate) struct Benchmark {
+    name: String,
+    scope: Option<String>,
+}
+
+impl Benchmark {
+    pub(crate) fn new(name: &str, scope: Option<&str>) -> Self {
+        Self {
+            name: name.to_string(),
+            scope: scope.map(str::to_string),
+        }
+    }
+
+    pub(crate) fn full_name(&self) -> String {
+        self.scope
+            .as_ref()
+            .map(|s| format!("{}::{}", self.name, s))
+            .unwrap_or_else(|| self.name.clone())
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
+pub(crate) enum Change {
+    New,
+    Improved,
+    Regressed,
+    Unchanged,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
+pub(crate) struct Values {
+    new: Option<u64>,
+    old: Option<u64>,
+}
+
+impl Values {
+    pub(crate) fn current(&self) -> Option<u64> {
+        self.new
+    }
+
+    pub(crate) fn abs_delta(&self) -> Option<i64> {
+        Some(self.new? as i64 - self.old? as i64)
+    }
+
+    pub(crate) fn percent_diff(&self) -> Option<f64> {
+        let delta = self.abs_delta()?;
+        let old = self.old?;
+
+        Some(if old == 0 {
+            match delta {
+                d if d < 0 => f64::NEG_INFINITY,
+                d if d > 0 => f64::INFINITY,
+                _ => 0.0,
+            }
+        } else {
+            delta as f64 / old as f64 * 100.0
+        })
+    }
+
+    pub(crate) fn fmt_current(&self) -> String {
+        self.current().map_or_else(String::new, |v| v.to_string())
+    }
+
+    pub(crate) fn fmt_human_current(&self) -> String {
+        self.current().map_or_else(String::new, fmt_human_u64)
+    }
+
+    pub(crate) fn fmt_human_percent(&self) -> String {
+        self.percent_diff()
+            .map_or_else(String::new, fmt_human_percent)
+    }
+
+    pub(crate) fn status(&self, noise_threshold: f64) -> Change {
+        match (self.new, self.old) {
+            (Some(_), Some(_)) => match self.percent_diff() {
+                Some(p) if p.abs() < noise_threshold => Change::Unchanged,
+                Some(p) if p < 0.0 => Change::Improved,
+                Some(_) => Change::Regressed,
+                None => Change::Unchanged,
+            },
+            (Some(_), None) => Change::New,
+            (None, Some(_)) => {
+                // This is actually a removed benchmark
+                // but we don't track it at the moment,
+                // so we treat it as unchanged.
+                Change::Unchanged
+            }
+            (None, None) => Change::Unchanged,
+        }
+    }
+}
+
+pub(crate) fn extract(
+    new_results: &BTreeMap<String, BenchResult>,
+    old_results: &BTreeMap<String, BenchResult>,
+) -> Vec<Entry> {
+    let mut results = Vec::new();
+
+    for (name, new_bench) in new_results {
+        let old_bench = old_results.get(name);
+
+        // Process total
+        let benchmark = Benchmark::new(name, None);
+        results.push(build_entry(
+            if old_bench.is_none() { "new" } else { "" }.to_string(),
+            benchmark,
+            Some(&new_bench.total),
+            old_bench.map(|b| &b.total),
+        ));
+
+        // Process scopes
+        for (scope, new_m) in &new_bench.scopes {
+            let old_m = old_bench.and_then(|b| b.scopes.get(scope));
+            let benchmark = Benchmark::new(name, Some(scope));
+            results.push(build_entry(
+                if old_m.is_none() { "new" } else { "" }.to_string(),
+                benchmark,
+                Some(new_m),
+                old_m,
+            ));
+        }
+    }
+
+    results
+}
+
+fn build_entry(
+    status: String,
+    benchmark: Benchmark,
+    new_m: Option<&Measurement>,
+    old_m: Option<&Measurement>,
+) -> Entry {
+    let extract_values = |f: fn(&Measurement) -> u64| Values {
+        new: new_m.map(f),
+        old: old_m.map(f),
+    };
+
+    Entry {
+        status,
+        benchmark,
+        instructions: extract_values(|m| m.instructions),
+        heap_increase: extract_values(|m| m.heap_increase),
+        stable_memory_increase: extract_values(|m| m.stable_memory_increase),
+    }
+}
diff --git a/canbench-bin/src/fmt.rs b/canbench-bin/src/fmt.rs
new file mode 100644
index 00000000..63f4c757
--- /dev/null
+++ b/canbench-bin/src/fmt.rs
@@ -0,0 +1,36 @@
+fn format_with_unit(val: f64) -> (f64, &'static str) {
+    const UNITS: &[(f64, &str)] = &[(1e12, "T"), (1e9, "B"), (1e6, "M"), (1e3, "K")];
+    for &(divisor, suffix) in UNITS {
+        if val.abs() >= divisor {
+            return (val / divisor, suffix);
+        }
+    }
+    (val, "")
+}
+
+pub(crate) fn fmt_human_u64(value: u64) -> String {
+    let (scaled, unit) = format_with_unit(value as f64);
+    match unit {
+        "" => format!("{scaled}"),
+        _ => format!("{scaled:.2}{unit}"),
+    }
+}
+
+pub(crate) fn fmt_human_i64(value: i64) -> String {
+    if value == 0 {
+        return "0".to_string(); // No sign for zero values.
+    }
+    let (scaled, unit) = format_with_unit(value as f64);
+    match unit {
+        "" => format!("{scaled:+.0}"),
+        _ => format!("{scaled:+.2}{unit}"),
+    }
+}
+
+pub(crate) fn fmt_human_percent(value: f64) -> String {
+    if value.abs() < 0.01 {
+        format!("{:.2}%", value)
+    } else {
+        format!("{:+.2}%", value)
+    }
+}
diff --git a/canbench-bin/src/lib.rs b/canbench-bin/src/lib.rs
index c482926f..e875fd70 100644
--- a/canbench-bin/src/lib.rs
+++ b/canbench-bin/src/lib.rs
@@ -1,9 +1,12 @@
 //! A module for running benchmarks.
 mod csv_file;
+mod data;
+mod fmt;
 mod instruction_tracing;
 mod print_benchmark;
 mod results_file;
 mod summary;
+mod table;
 
 use canbench_rs::BenchResult;
 use candid::{Encode, Principal};
@@ -130,16 +133,26 @@ pub fn run_benchmarks(
 
     println!("---------------------------------------------------");
 
+    let data = data::extract(&new_results, &old_results);
     if verbose || show_summary {
         println!();
-        summary::print_summary(&new_results, &old_results, noise_threshold);
+        summary::print_summary(&data, noise_threshold);
         println!();
         println!("---------------------------------------------------");
+
+        let filtered = table::filter_entries(&data, noise_threshold);
+        if !filtered.is_empty() {
+            println!();
+            println!("Only significant changes:");
+            table::print_table(&filtered);
+            println!();
+            println!("---------------------------------------------------");
+        }
     }
 
     // Save benchmark results in CSV format if requested.
     if csv {
-        csv_file::write(csv_results_file, &new_results, &old_results);
+        csv_file::write(csv_results_file, &data);
         println!(
             "Successfully saved CSV results to {}",
             csv_results_file.display()
diff --git a/canbench-bin/src/summary.rs b/canbench-bin/src/summary.rs
index 09eb554a..e2bea5d5 100644
--- a/canbench-bin/src/summary.rs
+++ b/canbench-bin/src/summary.rs
@@ -1,85 +1,55 @@
-use canbench_rs::{BenchResult, Measurement};
-use std::{collections::BTreeMap, f64};
+use crate::data::{Change, Entry, Values};
+use crate::fmt::{fmt_human_i64, fmt_human_percent};
+use std::f64;
 
-pub(crate) fn print_summary(
-    new: &BTreeMap<String, BenchResult>,
-    old: &BTreeMap<String, BenchResult>,
-    noise_threshold: f64,
-) {
+pub(crate) fn print_summary(data: &Vec<Entry>, noise_threshold: f64) {
     println!("Summary:");
-    print_metric_summary("instructions", new, old, noise_threshold, |m| {
-        m.instructions
-    });
+    print_metric_summary("instructions", data, noise_threshold, |e| &e.instructions);
     println!();
-    print_metric_summary("heap_increase", new, old, noise_threshold, |m| {
-        m.heap_increase
-    });
+    print_metric_summary("heap_increase", data, noise_threshold, |e| &e.heap_increase);
     println!();
-    print_metric_summary("stable_memory_increase", new, old, noise_threshold, |m| {
-        m.stable_memory_increase
+    print_metric_summary("stable_memory_increase", data, noise_threshold, |e| {
+        &e.stable_memory_increase
     });
 }
 
-fn print_metric_summary<F>(
-    label: &str,
-    new_results: &BTreeMap<String, BenchResult>,
-    old_results: &BTreeMap<String, BenchResult>,
-    noise_threshold: f64,
-    extractor: F,
-) where
-    F: Fn(&Measurement) -> u64,
+fn print_metric_summary<F>(label: &str, data: &Vec<Entry>, noise_threshold: f64, extractor: F)
+where
+    F: Fn(&Entry) -> &Values,
 {
+    let mut new = 0;
     let mut improved = 0;
     let mut regressed = 0;
     let mut unchanged = 0;
-    let mut new_only = 0;
 
     let mut abs_deltas = Vec::new();
     let mut percent_diffs = Vec::new();
 
-    for (name, new) in new_results {
-        let new_val = extractor(&new.total);
-        match old_results.get(name) {
-            Some(old) => {
-                let old_val = extractor(&old.total);
-                let abs_delta = new_val as i64 - old_val as i64;
-                abs_deltas.push(abs_delta);
+    let mut processed_entries = 0;
+    for entry in data {
+        // In summary only show the total measurements.
+        if entry.has_scope() {
+            continue;
+        }
+        processed_entries += 1;
 
-                if old_val == 0 {
-                    match abs_delta {
-                        d if d < 0 => {
-                            improved += 1;
-                            percent_diffs.push(f64::NEG_INFINITY);
-                        }
-                        d if d > 0 => {
-                            regressed += 1;
-                            percent_diffs.push(f64::INFINITY);
-                        }
-                        _ => {
-                            unchanged += 1;
-                            percent_diffs.push(0.0);
-                        }
-                    }
-                } else {
-                    let delta = abs_delta as f64 / old_val as f64 * 100.0;
-                    if delta.abs() < noise_threshold {
-                        unchanged += 1;
-                    } else if delta < 0.0 {
-                        improved += 1;
-                    } else {
-                        regressed += 1;
-                    }
-                    percent_diffs.push(delta);
-                }
-            }
-            None => {
-                new_only += 1;
-            }
+        let values = extractor(entry);
+        if let Some(delta) = values.abs_delta() {
+            abs_deltas.push(delta);
+        }
+        if let Some(percent) = values.percent_diff() {
+            percent_diffs.push(percent);
+        }
+        match values.status(noise_threshold) {
+            Change::New => new += 1,
+            Change::Improved => improved += 1,
+            Change::Regressed => regressed += 1,
+            Change::Unchanged => unchanged += 1,
         }
     }
 
-    let total = improved + regressed + unchanged + new_only;
-    debug_assert_eq!(total, new_results.len(), "total count mismatch");
+    let total = new + improved + regressed + unchanged;
+    debug_assert_eq!(total, processed_entries, "total count mismatch");
 
     println!("  {label}:");
     let status = match (improved, regressed) {
@@ -91,17 +61,22 @@ fn print_metric_summary<F>(
     println!("    status:   {status}");
     println!(
         "    counts:   [total {} | new {} | improved {} | regressed {} | unchanged {}]",
-        total, new_only, improved, regressed, unchanged
+        total, new, improved, regressed, unchanged
     );
 
     if !abs_deltas.is_empty() {
-        print_range("    change:  ", &abs_deltas, fmt_human, percentile_i64);
+        print_range("    change:  ", &abs_deltas, fmt_human_i64, percentile_i64);
     } else {
         println!("    change:   n/a");
     }
 
     if !percent_diffs.is_empty() {
-        print_range("    change %:", &percent_diffs, fmt_percent, percentile_f64);
+        print_range(
+            "    change %:",
+            &percent_diffs,
+            fmt_human_percent,
+            percentile_f64,
+        );
     } else {
         println!("    change %: n/a");
     }
@@ -156,23 +131,3 @@ fn percentile_i64(sorted: &[i64], pct: usize) -> i64 {
         (sorted[lower] as f64 * (1.0 - weight) + sorted[upper] as f64 * weight).round() as i64
     }
 }
-
-fn fmt_human(val: i64) -> String {
-    if val == 0 {
-        return "0".to_string(); // Don't show sign for zero values.
-    }
-    let val = val as f64;
-    for (divisor, suffix) in &[(1e12, "T"), (1e9, "B"), (1e6, "M"), (1e3, "K")] {
-        if val.abs() >= *divisor {
-            return format!("{:+.2} {}", val / divisor, suffix);
-        }
-    }
-    format!("{:+.0}", val)
-}
-
-fn fmt_percent(value: f64) -> String {
-    if value.abs() < 0.01 {
-        return format!("{:.2}%", value); // Don't show sign for zero values.
-    }
-    format!("{:+.2}%", value)
-}
diff --git a/canbench-bin/src/table.rs b/canbench-bin/src/table.rs
new file mode 100644
index 00000000..8a4967b0
--- /dev/null
+++ b/canbench-bin/src/table.rs
@@ -0,0 +1,131 @@
+use crate::data::{Change, Entry};
+
+pub(crate) fn filter_entries(data: &[Entry], noise_threshold: f64) -> Vec<Entry> {
+    let mut filtered: Vec<Entry> = data
+        .iter()
+        .filter_map(|entry| {
+            let metrics = [
+                &entry.instructions,
+                &entry.heap_increase,
+                &entry.stable_memory_increase,
+            ];
+
+            let is_significant = metrics.iter().any(|v| {
+                matches!(
+                    v.status(noise_threshold),
+                    Change::New | Change::Improved | Change::Regressed
+                )
+            });
+
+            if !is_significant {
+                return None;
+            }
+
+            let mut status = String::new();
+            if entry.status.is_empty() {
+                if metrics
+                    .iter()
+                    .any(|v| v.status(noise_threshold) == Change::Regressed)
+                {
+                    status.push('+');
+                }
+                if metrics
+                    .iter()
+                    .any(|v| v.status(noise_threshold) == Change::Improved)
+                {
+                    if !status.is_empty() {
+                        status.push('/');
+                    }
+                    status.push('-');
+                }
+            } else {
+                status = entry.status.clone();
+            }
+
+            let mut updated = entry.clone();
+            updated.status = status;
+            Some(updated)
+        })
+        .collect();
+
+    // Sort by name, ascending.
+    filtered.sort_by(|a, b| a.benchmark.full_name().cmp(&b.benchmark.full_name()));
+    // Sort by status.
+    filtered.sort_by(|a, b| a.status.cmp(&b.status));
+    // Sort by instructions percent diff, descending.
+    const EMPTY: f64 = f64::MIN;
+    filtered.sort_by(|a, b| {
+        a.instructions
+            .percent_diff()
+            .unwrap_or(EMPTY)
+            .partial_cmp(&b.instructions.percent_diff().unwrap_or(EMPTY))
+            .unwrap_or(std::cmp::Ordering::Equal)
+            .reverse()
+    });
+
+    filtered
+}
+
+pub(crate) fn print_table(data: &[Entry]) {
+    let columns = [
+        "status", "name", "ins", "ins Δ%", "HI", "HI Δ%", "SMI", "SMI Δ%",
+    ];
+
+    let mut rows = Vec::new();
+    for entry in data {
+        let name = entry.benchmark.full_name();
+        let row = [
+            entry.status.clone(),
+            name,
+            entry.instructions.fmt_human_current(), // Table report use short numbers
+            entry.instructions.fmt_human_percent(),
+            entry.heap_increase.fmt_human_current(),
+            entry.heap_increase.fmt_human_percent(),
+            entry.stable_memory_increase.fmt_human_current(),
+            entry.stable_memory_increase.fmt_human_percent(),
+        ];
+        rows.push(row);
+    }
+
+    // Calculate max column widths
+    let mut col_widths = columns
+        .iter()
+        .map(|header| header.len())
+        .collect::<Vec<_>>();
+
+    for row in &rows {
+        for (i, cell) in row.iter().enumerate() {
+            col_widths[i] = col_widths[i].max(cell.len());
+        }
+    }
+
+    // Helper to print a row with correct alignment and separators
+    let print_row = |row: &[String]| {
+        print!("|");
+        for (i, cell) in row.iter().enumerate() {
+            let width = col_widths[i];
+            match i {
+                0 => print!(" {:^width$} ", cell, width = width), // Center status
+                1 => print!(" {:<width$} ", cell, width = width), // Left-align name
+                _ => print!(" {:>width$} ", cell, width = width), // Right-align numbers
+            }
+            print!("|");
+        }
+        println!();
+    };
+
+    // Print header
+    print_row(&columns.iter().map(|s| s.to_string()).collect::<Vec<_>>());
+
+    // Print separator line
+    print!("|");
+    for width in &col_widths {
+        print!("{}|", "-".repeat(width + 2));
+    }
+    println!();
+
+    // Print data rows
+    for row in &rows {
+        print_row(row);
+    }
+}
diff --git a/canbench-bin/tests/expected/benchmark_heap_increase.txt b/canbench-bin/tests/expected/benchmark_heap_increase.txt
index ccea41a7..a5f010e4 100644
--- a/canbench-bin/tests/expected/benchmark_heap_increase.txt
+++ b/canbench-bin/tests/expected/benchmark_heap_increase.txt
@@ -28,3 +28,10 @@ Summary:
     change %: n/a
 
 ---------------------------------------------------
+
+Only significant changes:
+| status | name                   |   ins |  ins Δ% | HI |  HI Δ% | SMI |  SMI Δ% |
+|--------|------------------------|-------|---------|----|--------|-----|---------|
+|  new   | increase_heap_increase | 3.39M |         | 62 |        |   0 |         |
+
+---------------------------------------------------
diff --git a/canbench-bin/tests/expected/benchmark_instruction_tracing.txt b/canbench-bin/tests/expected/benchmark_instruction_tracing.txt
index 678b692d..483d2b3f 100644
--- a/canbench-bin/tests/expected/benchmark_instruction_tracing.txt
+++ b/canbench-bin/tests/expected/benchmark_instruction_tracing.txt
@@ -2,8 +2,8 @@
 
 Benchmark: write_stable_memory (new)
   total:
-    instructions: 49.74 K (new)
-    heap_increase: 1 pages (new)
+    instructions: 49.17 K (new)
+    heap_increase: 0 pages (new)
     stable_memory_increase: 1 pages (new)
 Instruction traces written to write_stable_memory.svg
 
@@ -29,3 +29,10 @@ Summary:
     change %: n/a
 
 ---------------------------------------------------
+
+Only significant changes:
+| status | name                |    ins |  ins Δ% | HI |  HI Δ% | SMI |  SMI Δ% |
+|--------|---------------------|--------|---------|----|--------|-----|---------|
+|  new   | write_stable_memory | 49.17K |         |  0 |        |   1 |         |
+
+---------------------------------------------------
diff --git a/canbench-bin/tests/expected/benchmark_reports_improvement.txt b/canbench-bin/tests/expected/benchmark_reports_improvement.txt
index 43a25ead..4f0b3181 100644
--- a/canbench-bin/tests/expected/benchmark_reports_improvement.txt
+++ b/canbench-bin/tests/expected/benchmark_reports_improvement.txt
@@ -12,7 +12,7 @@ Summary:
   instructions:
     status:   Improvements detected! 🟢
     counts:   [total 1 | new 0 | improved 1 | regressed 0 | unchanged 0]
-    change:   [min -2.89 K | med -2.89 K | max -2.89 K]
+    change:   [min -2.89K | med -2.89K | max -2.89K]
     change %: [min -93.32% | med -93.32% | max -93.32%]
 
   heap_increase:
@@ -28,3 +28,10 @@ Summary:
     change %: [min 0.00% | med 0.00% | max 0.00%]
 
 ---------------------------------------------------
+
+Only significant changes:
+| status | name             | ins |  ins Δ% | HI |  HI Δ% | SMI |  SMI Δ% |
+|--------|------------------|-----|---------|----|--------|-----|---------|
+|   -    | improvement_test | 207 | -93.32% |  0 |  0.00% |   0 |   0.00% |
+
+---------------------------------------------------
diff --git a/canbench-bin/tests/expected/benchmark_reports_noisy_change_above_default_noise_threshold.txt b/canbench-bin/tests/expected/benchmark_reports_noisy_change_above_default_noise_threshold.txt
index 97752271..a8d56d62 100644
--- a/canbench-bin/tests/expected/benchmark_reports_noisy_change_above_default_noise_threshold.txt
+++ b/canbench-bin/tests/expected/benchmark_reports_noisy_change_above_default_noise_threshold.txt
@@ -2,7 +2,7 @@
 
 Benchmark: noisy_change_above_default_threshold_test
   total:
-    instructions: 3.39 M (improved by 4.35%)
+    instructions: 3.39 M (improved by 4.36%)
     heap_increase: 62 pages (improved by 4.62%)
     stable_memory_increase: 100 pages (improved by 3.85%)
 
@@ -12,8 +12,8 @@ Summary:
   instructions:
     status:   Improvements detected! 🟢
     counts:   [total 1 | new 0 | improved 1 | regressed 0 | unchanged 0]
-    change:   [min -154.07 K | med -154.07 K | max -154.07 K]
-    change %: [min -4.35% | med -4.35% | max -4.35%]
+    change:   [min -154.17K | med -154.17K | max -154.17K]
+    change %: [min -4.36% | med -4.36% | max -4.36%]
 
   heap_increase:
     status:   Improvements detected! 🟢
@@ -28,3 +28,10 @@ Summary:
     change %: [min -3.85% | med -3.85% | max -3.85%]
 
 ---------------------------------------------------
+
+Only significant changes:
+| status | name                                      |   ins |  ins Δ% | HI |  HI Δ% | SMI |  SMI Δ% |
+|--------|-------------------------------------------|-------|---------|----|--------|-----|---------|
+|   -    | noisy_change_above_default_threshold_test | 3.39M |  -4.36% | 62 | -4.62% | 100 |  -3.85% |
+
+---------------------------------------------------
diff --git a/canbench-bin/tests/expected/benchmark_reports_noisy_change_within_custom_noise_threshold.txt b/canbench-bin/tests/expected/benchmark_reports_noisy_change_within_custom_noise_threshold.txt
index 2155e564..61af2280 100644
--- a/canbench-bin/tests/expected/benchmark_reports_noisy_change_within_custom_noise_threshold.txt
+++ b/canbench-bin/tests/expected/benchmark_reports_noisy_change_within_custom_noise_threshold.txt
@@ -2,7 +2,7 @@
 
 Benchmark: noisy_change_above_default_threshold_test
   total:
-    instructions: 3.39 M (-4.35%) (change within noise threshold)
+    instructions: 3.39 M (-4.36%) (change within noise threshold)
     heap_increase: 62 pages (-4.62%) (change within noise threshold)
     stable_memory_increase: 100 pages (-3.85%) (change within noise threshold)
 
@@ -12,8 +12,8 @@ Summary:
   instructions:
     status:   No significant changes detected 👍
     counts:   [total 1 | new 0 | improved 0 | regressed 0 | unchanged 1]
-    change:   [min -154.07 K | med -154.07 K | max -154.07 K]
-    change %: [min -4.35% | med -4.35% | max -4.35%]
+    change:   [min -154.17K | med -154.17K | max -154.17K]
+    change %: [min -4.36% | med -4.36% | max -4.36%]
 
   heap_increase:
     status:   No significant changes detected 👍
diff --git a/canbench-bin/tests/expected/benchmark_reports_regression.txt b/canbench-bin/tests/expected/benchmark_reports_regression.txt
index 86ecf103..cb45ffbc 100644
--- a/canbench-bin/tests/expected/benchmark_reports_regression.txt
+++ b/canbench-bin/tests/expected/benchmark_reports_regression.txt
@@ -28,3 +28,10 @@ Summary:
     change %: [min 0.00% | med 0.00% | max 0.00%]
 
 ---------------------------------------------------
+
+Only significant changes:
+| status | name            | ins |    ins Δ% | HI |  HI Δ% | SMI |  SMI Δ% |
+|--------|-----------------|-----|-----------|----|--------|-----|---------|
+|   +    | regression_test | 207 | +1970.00% |  0 |  0.00% |   0 |   0.00% |
+
+---------------------------------------------------
diff --git a/canbench-bin/tests/expected/benchmark_reports_regression_from_zero.txt b/canbench-bin/tests/expected/benchmark_reports_regression_from_zero.txt
index 9a995eee..7f1e8579 100644
--- a/canbench-bin/tests/expected/benchmark_reports_regression_from_zero.txt
+++ b/canbench-bin/tests/expected/benchmark_reports_regression_from_zero.txt
@@ -28,3 +28,10 @@ Summary:
     change %: [min +inf% | med +inf% | max +inf%]
 
 ---------------------------------------------------
+
+Only significant changes:
+| status | name                             | ins |  ins Δ% | HI |  HI Δ% | SMI |  SMI Δ% |
+|--------|----------------------------------|-----|---------|----|--------|-----|---------|
+|   +    | stable_memory_increase_from_zero | 307 |   +inf% |  0 |  0.00% | 123 |   +inf% |
+
+---------------------------------------------------
diff --git a/canbench-bin/tests/expected/benchmark_stable_memory_increase.txt b/canbench-bin/tests/expected/benchmark_stable_memory_increase.txt
index 407b5c80..23e8025f 100644
--- a/canbench-bin/tests/expected/benchmark_stable_memory_increase.txt
+++ b/canbench-bin/tests/expected/benchmark_stable_memory_increase.txt
@@ -28,3 +28,10 @@ Summary:
     change %: n/a
 
 ---------------------------------------------------
+
+Only significant changes:
+| status | name                        | ins |  ins Δ% | HI |  HI Δ% | SMI |  SMI Δ% |
+|--------|-----------------------------|-----|---------|----|--------|-----|---------|
+|  new   | stable_memory_only_increase | 307 |         |  0 |        | 456 |         |
+
+---------------------------------------------------
diff --git a/canbench-bin/tests/expected/benchmark_stable_writes.txt b/canbench-bin/tests/expected/benchmark_stable_writes.txt
index dd416111..586c6cd8 100644
--- a/canbench-bin/tests/expected/benchmark_stable_writes.txt
+++ b/canbench-bin/tests/expected/benchmark_stable_writes.txt
@@ -2,8 +2,8 @@
 
 Benchmark: write_stable_memory (new)
   total:
-    instructions: 49.74 K (new)
-    heap_increase: 1 pages (new)
+    instructions: 49.17 K (new)
+    heap_increase: 0 pages (new)
     stable_memory_increase: 1 pages (new)
 
 ---------------------------------------------------
@@ -28,3 +28,10 @@ Summary:
     change %: n/a
 
 ---------------------------------------------------
+
+Only significant changes:
+| status | name                |    ins |  ins Δ% | HI |  HI Δ% | SMI |  SMI Δ% |
+|--------|---------------------|--------|---------|----|--------|-----|---------|
+|  new   | write_stable_memory | 49.17K |         |  0 |        |   1 |         |
+
+---------------------------------------------------
diff --git a/canbench-bin/tests/expected/benchmark_works_with_init_args.txt b/canbench-bin/tests/expected/benchmark_works_with_init_args.txt
index ac5843aa..13058388 100644
--- a/canbench-bin/tests/expected/benchmark_works_with_init_args.txt
+++ b/canbench-bin/tests/expected/benchmark_works_with_init_args.txt
@@ -2,7 +2,7 @@
 
 Benchmark: state_check
   total:
-    instructions: 872 (regressed by 3.69%)
+    instructions: 841 (no change)
     heap_increase: 0 pages (no change)
     stable_memory_increase: 0 pages (no change)
 
@@ -10,10 +10,10 @@ Benchmark: state_check
 
 Summary:
   instructions:
-    status:   Regressions detected! 🔴
-    counts:   [total 1 | new 0 | improved 0 | regressed 1 | unchanged 0]
-    change:   [min +31 | med +31 | max +31]
-    change %: [min +3.69% | med +3.69% | max +3.69%]
+    status:   No significant changes detected 👍
+    counts:   [total 1 | new 0 | improved 0 | regressed 0 | unchanged 1]
+    change:   [min 0 | med 0 | max 0]
+    change %: [min 0.00% | med 0.00% | max 0.00%]
 
   heap_increase:
     status:   No significant changes detected 👍
diff --git a/canbench-bin/tests/expected/reports_repeated_scope_in_existing_benchmark.txt b/canbench-bin/tests/expected/reports_repeated_scope_in_existing_benchmark.txt
index 2189776a..e9a84651 100644
--- a/canbench-bin/tests/expected/reports_repeated_scope_in_existing_benchmark.txt
+++ b/canbench-bin/tests/expected/reports_repeated_scope_in_existing_benchmark.txt
@@ -2,13 +2,13 @@
 
 Benchmark: bench_repeated_scope_exists
   total:
-    instructions: 16.97 K (regressed from 0)
-    heap_increase: 1 pages (regressed from 0)
+    instructions: 16.48 K (regressed from 0)
+    heap_increase: 0 pages (no change)
     stable_memory_increase: 0 pages (no change)
 
   scope_1 (scope):
-    instructions: 8694 (regressed by 986.75%)
-    heap_increase: 1 pages (improved by 91.67%)
+    instructions: 8124 (regressed by 915.50%)
+    heap_increase: 0 pages (improved by 100.00%)
     stable_memory_increase: 0 pages (no change)
 
 ---------------------------------------------------
@@ -17,14 +17,14 @@ Summary:
   instructions:
     status:   Regressions detected! 🔴
     counts:   [total 1 | new 0 | improved 0 | regressed 1 | unchanged 0]
-    change:   [min +16.97 K | med +16.97 K | max +16.97 K]
+    change:   [min +16.48K | med +16.48K | max +16.48K]
     change %: [min +inf% | med +inf% | max +inf%]
 
   heap_increase:
-    status:   Regressions detected! 🔴
-    counts:   [total 1 | new 0 | improved 0 | regressed 1 | unchanged 0]
-    change:   [min +1 | med +1 | max +1]
-    change %: [min +inf% | med +inf% | max +inf%]
+    status:   No significant changes detected 👍
+    counts:   [total 1 | new 0 | improved 0 | regressed 0 | unchanged 1]
+    change:   [min 0 | med 0 | max 0]
+    change %: [min 0.00% | med 0.00% | max 0.00%]
 
   stable_memory_increase:
     status:   No significant changes detected 👍
@@ -33,3 +33,11 @@ Summary:
     change %: [min 0.00% | med 0.00% | max 0.00%]
 
 ---------------------------------------------------
+
+Only significant changes:
+| status | name                                 |    ins |   ins Δ% | HI |    HI Δ% | SMI |  SMI Δ% |
+|--------|--------------------------------------|--------|----------|----|----------|-----|---------|
+|   +    | bench_repeated_scope_exists          | 16.48K |    +inf% |  0 |    0.00% |   0 |   0.00% |
+|  +/-   | bench_repeated_scope_exists::scope_1 |  8.12K | +915.50% |  0 | -100.00% |   0 |   0.00% |
+
+---------------------------------------------------
diff --git a/canbench-bin/tests/expected/reports_repeated_scope_in_new_benchmark.txt b/canbench-bin/tests/expected/reports_repeated_scope_in_new_benchmark.txt
index 140cd26b..50e22c90 100644
--- a/canbench-bin/tests/expected/reports_repeated_scope_in_new_benchmark.txt
+++ b/canbench-bin/tests/expected/reports_repeated_scope_in_new_benchmark.txt
@@ -2,13 +2,13 @@
 
 Benchmark: bench_repeated_scope_new (new)
   total:
-    instructions: 16.97 K (new)
-    heap_increase: 1 pages (new)
+    instructions: 16.48 K (new)
+    heap_increase: 0 pages (new)
     stable_memory_increase: 0 pages (new)
 
   scope_1 (scope):
-    instructions: 8694 (new)
-    heap_increase: 1 pages (new)
+    instructions: 8124 (new)
+    heap_increase: 0 pages (new)
     stable_memory_increase: 0 pages (new)
 
 ---------------------------------------------------
@@ -33,3 +33,11 @@ Summary:
     change %: n/a
 
 ---------------------------------------------------
+
+Only significant changes:
+| status | name                              |    ins |  ins Δ% | HI |  HI Δ% | SMI |  SMI Δ% |
+|--------|-----------------------------------|--------|---------|----|--------|-----|---------|
+|  new   | bench_repeated_scope_new          | 16.48K |         |  0 |        |   0 |         |
+|  new   | bench_repeated_scope_new::scope_1 |  8.12K |         |  0 |        |   0 |         |
+
+---------------------------------------------------
diff --git a/canbench-bin/tests/expected/reports_scopes_in_existing_benchmark.txt b/canbench-bin/tests/expected/reports_scopes_in_existing_benchmark.txt
index be507418..9daff4de 100644
--- a/canbench-bin/tests/expected/reports_scopes_in_existing_benchmark.txt
+++ b/canbench-bin/tests/expected/reports_scopes_in_existing_benchmark.txt
@@ -2,13 +2,13 @@
 
 Benchmark: bench_scope_exists
   total:
-    instructions: 4626 (regressed from 0)
-    heap_increase: 1 pages (regressed from 0)
+    instructions: 4138 (regressed from 0)
+    heap_increase: 0 pages (no change)
     stable_memory_increase: 0 pages (no change)
 
   scope_1 (scope):
-    instructions: 1620 (regressed by 102.50%)
-    heap_increase: 1 pages (improved by 91.67%)
+    instructions: 1050 (regressed by 31.25%)
+    heap_increase: 0 pages (improved by 100.00%)
     stable_memory_increase: 0 pages (no change)
 
   scope_2 (scope):
@@ -22,14 +22,14 @@ Summary:
   instructions:
     status:   Regressions detected! 🔴
     counts:   [total 1 | new 0 | improved 0 | regressed 1 | unchanged 0]
-    change:   [min +4.63 K | med +4.63 K | max +4.63 K]
+    change:   [min +4.14K | med +4.14K | max +4.14K]
     change %: [min +inf% | med +inf% | max +inf%]
 
   heap_increase:
-    status:   Regressions detected! 🔴
-    counts:   [total 1 | new 0 | improved 0 | regressed 1 | unchanged 0]
-    change:   [min +1 | med +1 | max +1]
-    change %: [min +inf% | med +inf% | max +inf%]
+    status:   No significant changes detected 👍
+    counts:   [total 1 | new 0 | improved 0 | regressed 0 | unchanged 1]
+    change:   [min 0 | med 0 | max 0]
+    change %: [min 0.00% | med 0.00% | max 0.00%]
 
   stable_memory_increase:
     status:   No significant changes detected 👍
@@ -38,3 +38,12 @@ Summary:
     change %: [min 0.00% | med 0.00% | max 0.00%]
 
 ---------------------------------------------------
+
+Only significant changes:
+| status | name                        |   ins |  ins Δ% | HI |    HI Δ% | SMI |  SMI Δ% |
+|--------|-----------------------------|-------|---------|----|----------|-----|---------|
+|   +    | bench_scope_exists          | 4.14K |   +inf% |  0 |    0.00% |   0 |   0.00% |
+|  +/-   | bench_scope_exists::scope_1 | 1.05K | +31.25% |  0 | -100.00% |   0 |   0.00% |
+|  new   | bench_scope_exists::scope_2 |   786 |         |  0 |          |   0 |         |
+
+---------------------------------------------------
diff --git a/canbench-bin/tests/expected/reports_scopes_in_new_benchmark.txt b/canbench-bin/tests/expected/reports_scopes_in_new_benchmark.txt
index 4cba2760..f9eddce1 100644
--- a/canbench-bin/tests/expected/reports_scopes_in_new_benchmark.txt
+++ b/canbench-bin/tests/expected/reports_scopes_in_new_benchmark.txt
@@ -2,13 +2,13 @@
 
 Benchmark: bench_scope_new (new)
   total:
-    instructions: 4626 (new)
-    heap_increase: 1 pages (new)
+    instructions: 4138 (new)
+    heap_increase: 0 pages (new)
     stable_memory_increase: 0 pages (new)
 
   scope_1 (scope):
-    instructions: 1620 (new)
-    heap_increase: 1 pages (new)
+    instructions: 1050 (new)
+    heap_increase: 0 pages (new)
     stable_memory_increase: 0 pages (new)
 
   scope_2 (scope):
@@ -38,3 +38,12 @@ Summary:
     change %: n/a
 
 ---------------------------------------------------
+
+Only significant changes:
+| status | name                     |   ins |  ins Δ% | HI |  HI Δ% | SMI |  SMI Δ% |
+|--------|--------------------------|-------|---------|----|--------|-----|---------|
+|  new   | bench_scope_new          | 4.14K |         |  0 |        |   0 |         |
+|  new   | bench_scope_new::scope_1 | 1.05K |         |  0 |        |   0 |         |
+|  new   | bench_scope_new::scope_2 |   786 |         |  0 |        |   0 |         |
+
+---------------------------------------------------
diff --git a/canbench-bin/tests/expected/supports_gzipped_wasm.txt b/canbench-bin/tests/expected/supports_gzipped_wasm.txt
index 05722f62..a01b9536 100644
--- a/canbench-bin/tests/expected/supports_gzipped_wasm.txt
+++ b/canbench-bin/tests/expected/supports_gzipped_wasm.txt
@@ -36,3 +36,11 @@ Summary:
     change %: n/a
 
 ---------------------------------------------------
+
+Only significant changes:
+| status | name    | ins |  ins Δ% | HI |  HI Δ% | SMI |  SMI Δ% |
+|--------|---------|-----|---------|----|--------|-----|---------|
+|  new   | bench_1 | 207 |         |  0 |        |   0 |         |
+|  new   | bench_2 | 207 |         |  0 |        |   0 |         |
+
+---------------------------------------------------
diff --git a/examples/btreemap_vs_hashmap/canbench_results.csv b/examples/btreemap_vs_hashmap/canbench_results.csv
index 91d62f98..c5ca25ed 100644
--- a/examples/btreemap_vs_hashmap/canbench_results.csv
+++ b/examples/btreemap_vs_hashmap/canbench_results.csv
@@ -1,6 +1,6 @@
 status,name,instructions,instructions change %,heap_increase,heap_increase change %,stable_memory_increase,stable_memory_increase change %
-,insert_users,2558172740,0.00%,872,0.00%,0,0.00%
-,pre_upgrade_bench,438878782,0.00%,519,0.00%,184,0.00%
-,pre_upgrade_bench::serialize_state,423945877,0.00%,519,0.00%,0,0.00%
+,insert_users,2558172165,-0.00%,871,-0.11%,0,0.00%
+,pre_upgrade_bench,438872727,-0.00%,519,0.00%,184,0.00%
+,pre_upgrade_bench::serialize_state,423939828,-0.00%,519,0.00%,0,0.00%
 ,pre_upgrade_bench::writing_to_stable_memory,14930542,0.00%,0,0.00%,184,0.00%
-,remove_users,2109060849,0.00%,0,0.00%,0,0.00%
+,remove_users,2109136823,0.00%,0,0.00%,0,0.00%
diff --git a/examples/btreemap_vs_hashmap/canbench_results.yml b/examples/btreemap_vs_hashmap/canbench_results.yml
index 8cc5b05b..68d71251 100644
--- a/examples/btreemap_vs_hashmap/canbench_results.yml
+++ b/examples/btreemap_vs_hashmap/canbench_results.yml
@@ -1,18 +1,18 @@
 benches:
   insert_users:
     total:
-      instructions: 2558172740
-      heap_increase: 872
+      instructions: 2558172165
+      heap_increase: 871
       stable_memory_increase: 0
     scopes: {}
   pre_upgrade_bench:
     total:
-      instructions: 438878782
+      instructions: 438872727
       heap_increase: 519
       stable_memory_increase: 184
     scopes:
       serialize_state:
-        instructions: 423945877
+        instructions: 423939828
         heap_increase: 519
         stable_memory_increase: 0
       writing_to_stable_memory:
@@ -21,8 +21,8 @@ benches:
         stable_memory_increase: 184
   remove_users:
     total:
-      instructions: 2109060849
+      instructions: 2109136823
       heap_increase: 0
       stable_memory_increase: 0
     scopes: {}
-version: 0.1.12
+version: 0.1.13
diff --git a/examples/fibonacci/canbench_results.yml b/examples/fibonacci/canbench_results.yml
index 86d5026a..8317687a 100644
--- a/examples/fibonacci/canbench_results.yml
+++ b/examples/fibonacci/canbench_results.yml
@@ -29,4 +29,4 @@ benches:
       heap_increase: 0
       stable_memory_increase: 0
     scopes: {}
-version: 0.1.12
+version: 0.1.13


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR refactors the benchmark data table and summary reporting by switching from separate old/new result maps to a unified Entry data structure, and updates the expected test outputs accordingly. Key changes include:
- Updated test expectation files to include a clearly formatted table of significant changes.
- New implementations for filtering and printing output tables in table.rs.
- Refactored summary and CSV writing logic to operate on the unified Entry structure.

### Reviewed Changes

Copilot reviewed 21 out of 21 changed files in this pull request and generated 1 comment.

<details>
<summary>Show a summary per file</summary>

| File | Description |
| ---- | ----------- |
| canbench-bin/tests/expected/*.txt | Updated expected output tables with a new header ("Only significant changes:") and column formatting. |
| canbench-bin/src/table.rs | Added functions for filtering and printing tables based on significant changes. |
| canbench-bin/src/summary.rs | Refactored summary reporting to use the new Entry structure and filter out scoped entries. |
| canbench-bin/src/lib.rs | Adapted benchmark runner to extract data and integrate new summary and table modules. |
| canbench-bin/src/fmt.rs | Introduced new formatting functions for numbers and percentages. |
| canbench-bin/src/data.rs | Created a new unified data model (Entry) and extraction logic from benchmark results. |
| canbench-bin/src/csv_file.rs | Updated CSV writing logic to work with the new unified Entry structure. |
</details>



<details>
<summary>Comments suppressed due to low confidence (1)</summary>

**canbench-bin/src/summary.rs:31**
* [nitpick] Consider adding a comment to explain why entries with a scope are being skipped in the summary calculations for improved clarity and maintainability.
```
if entry.has_scope() { continue; }
```
</details>


state: COMMENTED
author_association: NONE
submitted_at: 2025-05-13T15:28:54Z
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR refactors the data table rendering logic by unifying benchmark results into a new Entry struct and updating how summaries, tables, and CSV exports are handled. Key changes include:
- Converting benchmark results from separate maps into a unified Entry vector via a new data extraction function.
- Refactoring the table module to filter and print entries based on significant changes.
- Updating the summary and CSV export logic to work with the new data structure.

### Reviewed Changes

Copilot reviewed 21 out of 21 changed files in this pull request and generated 2 comments.

<details>
<summary>Show a summary per file</summary>

| File                                                                 | Description                                                    |
|----------------------------------------------------------------------|----------------------------------------------------------------|
| canbench-bin/tests/expected/*.txt                                    | Updated expected output to reflect new table and status format |
| canbench-bin/src/table.rs                                            | New functions for filtering and printing the data table        |
| canbench-bin/src/summary.rs                                           | Refactored summary printing to consume unified Entry data       |
| canbench-bin/src/lib.rs                                               | Updated integration of new data extraction and table rendering   |
| canbench-bin/src/fmt.rs                                               | Added formatting helpers for numbers                            |
| canbench-bin/src/data.rs                                              | Introduced the Entry struct and data extraction logic           |
| canbench-bin/src/csv_file.rs                                          | Revised CSV export logic to write unified Entry data              |
</details>





state: COMMENTED
author_association: NONE
submitted_at: 2025-05-13T15:37:32Z
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR introduces a results table to the summary output, unifies value formatting across CSV reports, short summaries, and the results table, and sorts benchmark results to highlight major regressions.  
- Adds a new table printing module with multi-level sorting  
- Updates the summary reporting to process extracted benchmark data  
- Revises expected test output files and CSV generation for consistent formatting

### Reviewed Changes

Copilot reviewed 24 out of 24 changed files in this pull request and generated 2 comments.

<details>
<summary>Show a summary per file</summary>

| File                                                                                   | Description                                                    |
|----------------------------------------------------------------------------------------|----------------------------------------------------------------|
| canbench-bin/tests/expected/*.txt                                                      | Updates expected outputs across various report files           |
| canbench-bin/src/table.rs                                                                | Adds table filtering, sorting, and printing functionality       |
| canbench-bin/src/summary.rs                                                              | Refactors summary to use extracted data and update metrics display|
| canbench-bin/src/lib.rs                                                                  | Integrates new data extraction and table printing in run flow    |
| canbench-bin/src/fmt.rs                                                                  | Introduces human-readable formatting functions                    |
| canbench-bin/src/data.rs                                                                 | Implements data extraction and encapsulates measurement logic     |
| canbench-bin/src/csv_file.rs                                                             | Revises CSV writer to use unified formatted entries               |
</details>





state: COMMENTED
author_association: NONE
submitted_at: 2025-05-14T13:49:18Z
user: login: mraszyk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T14:09:01Z
user: login: mraszyk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T14:11:24Z
user: login: maksymar
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T14:12:16Z
user: login: maksymar
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T14:15:05Z
user: login: mraszyk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T14:19:35Z
user: login: mraszyk
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T14:21:57Z
user: login: maksymar
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T15:17:11Z

Review comments:
diff_hunk: @@ -0,0 +1,156 @@
+use crate::fmt::{fmt_change, fmt_current, fmt_percent};
+use canbench_rs::{BenchResult, Measurement};
+use std::collections::BTreeMap;
+
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
+pub(crate) struct Entry {
+    pub(crate) status: String,
+    pub(crate) benchmark: Benchmark,
+    pub(crate) instructions: Values,
+    pub(crate) heap_increase: Values,
+    pub(crate) stable_memory_increase: Values,
+}
+
+impl Entry {
+    pub(crate) fn has_scope(&self) -> bool {
+        self.benchmark.scope.is_some()
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
+pub(crate) struct Benchmark {
+    name: String,
+    scope: Option<String>,
+}
+
+impl Benchmark {
+    pub(crate) fn new(name: &str, scope: Option<&str>) -> Self {
+        Self {
+            name: name.to_string(),
+            scope: scope.map(str::to_string),
+        }
+    }
+
+    pub(crate) fn full_name(&self) -> String {
+        self.scope
+            .as_ref()
+            .map(|s| format!("{}::{}", self.name, s))
+            .unwrap_or_else(|| self.name.clone())
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
+pub(crate) enum Change {
+    Unchanged,
+    New,
+    Improved,
+    Regressed,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
+pub(crate) struct Values {
+    new: Option<u64>,
+    old: Option<u64>,
+}
+
+impl Values {
+    pub(crate) fn current(&self) -> Option<u64> {
+        self.new
+    }
+
+    pub(crate) fn abs_delta(&self) -> Option<i64> {
+        Some(self.new? as i64 - self.old? as i64)
+    }
+
+    pub(crate) fn percent_diff(&self) -> Option<f64> {
+        let delta = self.abs_delta()?;
+        let old = self.old?;
+
+        Some(if old == 0 {
+            match delta {
+                d if d < 0 => f64::NEG_INFINITY,
+                d if d > 0 => f64::INFINITY,
+                _ => 0.0,
+            }
+        } else {
+            delta as f64 / old as f64 * 100.0
+        })
+    }
+
+    pub(crate) fn fmt_current(&self) -> String {
+        self.current().map_or_else(|| "".to_string(), fmt_current)
+    }
+
+    pub(crate) fn fmt_change(&self) -> String {
+        self.abs_delta().map_or_else(|| "".to_string(), fmt_change)
+    }
+
+    pub(crate) fn fmt_percent(&self) -> String {
+        self.percent_diff()
+            .map_or_else(|| "".to_string(), fmt_percent)
+    }
+
+    pub(crate) fn status(&self, noise_threshold: f64) -> Change {
+        match (self.new, self.old) {
+            (Some(_), Some(_)) => match self.percent_diff() {
+                Some(p) if p.abs() < noise_threshold => Change::Unchanged,
+                Some(p) if p < 0.0 => Change::Improved,
+                Some(_) => Change::Regressed,
+                _ => unreachable!(),
+            },
+            (Some(_), None) => Change::New,
+            _ => unreachable!(),
+        }
+    }
+}
+
+pub(crate) fn extract(
+    new_results: &BTreeMap<String, BenchResult>,
+    old_results: &BTreeMap<String, BenchResult>,
+) -> Vec<Entry> {
+    let mut results = Vec::new();
+
+    let compute_status = |old_present: bool| if old_present { "" } else { "new" };
path: canbench-bin/src/data.rs
user: login: Copilot
body: [nitpick] Consider documenting the rationale behind using an empty string to represent non-new benchmarks, to avoid potential confusion when displaying the status in tables.
```suggestion
    // The `status` field indicates whether a benchmark is new or unchanged.
    // "new" is used for benchmarks that are present in the new results but not in the old results.
    // "unchanged" is used for benchmarks that are present in both the new and old results.
    let compute_status = |old_present: bool| if old_present { "unchanged" } else { "new" };
```
created_at: 2025-05-13T15:28:53Z
author_association: NONE
diff_hunk: @@ -0,0 +1,97 @@
+use crate::data::Entry;
+
+pub(crate) fn filter_entries(data: &[Entry], noise_threshold: f64) -> Vec<&Entry> {
+    let mut filtered: Vec<_> = data
+        .iter()
+        .filter(|entry| {
+            [
+                &entry.instructions,
+                &entry.heap_increase,
+                &entry.stable_memory_increase,
+            ]
+            .iter()
+            .any(|values| {
+                matches!(
+                    values.status(noise_threshold),
+                    crate::data::Change::New
+                        | crate::data::Change::Improved
+                        | crate::data::Change::Regressed
+                )
+            })
+        })
+        .collect();
+
+    filtered.sort_by(|a, b| {
+        a.instructions
+            .percent_diff()
+            .unwrap_or(0.0)
+            .partial_cmp(&b.instructions.percent_diff().unwrap_or(0.0))
+            .unwrap_or(std::cmp::Ordering::Equal)
path: canbench-bin/src/table.rs
user: login: Copilot
body: The current sorting uses unwrap_or(0.0) on percent_diff, which may inadvertently treat infinite values as 0. Consider handling cases where percent_diff returns infinity explicitly to ensure the sort order reflects the intended significance.
```suggestion
        let a_val = a.instructions.percent_diff().unwrap_or(0.0);
        let b_val = b.instructions.percent_diff().unwrap_or(0.0);
        
        let a_val = if a_val.is_infinite() {
            if a_val.is_sign_positive() { f64::MAX } else { f64::MIN }
        } else if a_val.is_nan() {
            f64::MIN
        } else {
            a_val
        };
        
        let b_val = if b_val.is_infinite() {
            if b_val.is_sign_positive() { f64::MAX } else { f64::MIN }
        } else if b_val.is_nan() {
            f64::MIN
        } else {
            b_val
        };
        
        a_val.partial_cmp(&b_val).unwrap_or(std::cmp::Ordering::Equal)
```
created_at: 2025-05-13T15:37:31Z
author_association: NONE
diff_hunk: @@ -1,85 +1,55 @@
-use canbench_rs::{BenchResult, Measurement};
-use std::{collections::BTreeMap, f64};
+use crate::data::{Change, Entry, Values};
+use crate::fmt::{fmt_change, fmt_percent};
+use std::f64;
 
-pub(crate) fn print_summary(
-    new: &BTreeMap<String, BenchResult>,
-    old: &BTreeMap<String, BenchResult>,
-    noise_threshold: f64,
-) {
+pub(crate) fn print_summary(data: &Vec<Entry>, noise_threshold: f64) {
     println!("Summary:");
-    print_metric_summary("instructions", new, old, noise_threshold, |m| {
-        m.instructions
-    });
+    print_metric_summary("instructions", data, noise_threshold, |e| &e.instructions);
     println!();
-    print_metric_summary("heap_increase", new, old, noise_threshold, |m| {
-        m.heap_increase
-    });
+    print_metric_summary("heap_increase", data, noise_threshold, |e| &e.heap_increase);
     println!();
-    print_metric_summary("stable_memory_increase", new, old, noise_threshold, |m| {
-        m.stable_memory_increase
+    print_metric_summary("stable_memory_increase", data, noise_threshold, |e| {
+        &e.stable_memory_increase
     });
 }
 
-fn print_metric_summary<F>(
-    label: &str,
-    new_results: &BTreeMap<String, BenchResult>,
-    old_results: &BTreeMap<String, BenchResult>,
-    noise_threshold: f64,
-    extractor: F,
-) where
-    F: Fn(&Measurement) -> u64,
+fn print_metric_summary<F>(label: &str, data: &Vec<Entry>, noise_threshold: f64, extractor: F)
+where
+    F: Fn(&Entry) -> &Values,
 {
+    let mut new = 0;
     let mut improved = 0;
     let mut regressed = 0;
     let mut unchanged = 0;
-    let mut new_only = 0;
 
     let mut abs_deltas = Vec::new();
     let mut percent_diffs = Vec::new();
 
-    for (name, new) in new_results {
-        let new_val = extractor(&new.total);
-        match old_results.get(name) {
-            Some(old) => {
-                let old_val = extractor(&old.total);
-                let abs_delta = new_val as i64 - old_val as i64;
-                abs_deltas.push(abs_delta);
+    let mut processed_entries = 0;
+    for entry in data {
+        // In summary only show the total measurements.
+        if entry.has_scope() {
+            continue;
+        }
+        processed_entries += 1;
 
-                if old_val == 0 {
-                    match abs_delta {
-                        d if d < 0 => {
-                            improved += 1;
-                            percent_diffs.push(f64::NEG_INFINITY);
-                        }
-                        d if d > 0 => {
-                            regressed += 1;
-                            percent_diffs.push(f64::INFINITY);
-                        }
-                        _ => {
-                            unchanged += 1;
-                            percent_diffs.push(0.0);
-                        }
-                    }
-                } else {
-                    let delta = abs_delta as f64 / old_val as f64 * 100.0;
-                    if delta.abs() < noise_threshold {
-                        unchanged += 1;
-                    } else if delta < 0.0 {
-                        improved += 1;
-                    } else {
-                        regressed += 1;
-                    }
-                    percent_diffs.push(delta);
-                }
-            }
-            None => {
-                new_only += 1;
-            }
+        let values = extractor(entry);
+        if let Some(delta) = values.abs_delta() {
+            abs_deltas.push(delta);
+        }
+        if let Some(percent) = values.percent_diff() {
+            percent_diffs.push(percent);
+        }
+        match values.status(noise_threshold) {
+            Change::New => new += 1,
+            Change::Improved => improved += 1,
+            Change::Regressed => regressed += 1,
+            Change::Unchanged => unchanged += 1,
         }
     }
 
-    let total = improved + regressed + unchanged + new_only;
-    debug_assert_eq!(total, new_results.len(), "total count mismatch");
+    let total = new + improved + regressed + unchanged;
+    debug_assert_eq!(total, processed_entries, "total count mismatch");
path: canbench-bin/src/summary.rs
user: login: Copilot
body: [nitpick] Since only non-scoped entries are processed in the summary, adding a clarifying comment here could improve readability and maintainability.
created_at: 2025-05-13T15:37:31Z
author_association: NONE
diff_hunk: @@ -0,0 +1,131 @@
+use crate::data::{Change, Entry};
+
+pub(crate) fn filter_entries(data: &[Entry], noise_threshold: f64) -> Vec<Entry> {
+    let mut filtered: Vec<Entry> = data
+        .iter()
+        .filter_map(|entry| {
+            let metrics = [
+                &entry.instructions,
+                &entry.heap_increase,
+                &entry.stable_memory_increase,
+            ];
+
+            let is_significant = metrics.iter().any(|v| {
+                matches!(
+                    v.status(noise_threshold),
+                    Change::New | Change::Improved | Change::Regressed
+                )
+            });
+
+            if !is_significant {
+                return None;
+            }
+
+            let mut status = String::new();
+            if entry.status.is_empty() {
+                if metrics
+                    .iter()
+                    .any(|v| v.status(noise_threshold) == Change::Regressed)
+                {
+                    status.push('+');
+                }
+                if metrics
+                    .iter()
+                    .any(|v| v.status(noise_threshold) == Change::Improved)
+                {
+                    if !status.is_empty() {
+                        status.push('/');
+                    }
+                    status.push('-');
+                }
+            } else {
+                status = entry.status.clone();
+            }
+
+            let mut updated = entry.clone();
+            updated.status = status;
+            Some(updated)
+        })
+        .collect();
+
+    // Sort by name, ascending.
+    filtered.sort_by(|a, b| a.benchmark.full_name().cmp(&b.benchmark.full_name()));
+    // Sort by status.
+    filtered.sort_by(|a, b| a.status.cmp(&b.status));
+    // Sort by instructions percent diff, descending.
+    const EMPTY: f64 = f64::MIN;
+    filtered.sort_by(|a, b| {
+        a.instructions
+            .percent_diff()
+            .unwrap_or(EMPTY)
+            .partial_cmp(&b.instructions.percent_diff().unwrap_or(EMPTY))
+            .unwrap_or(std::cmp::Ordering::Equal)
+            .reverse()
path: canbench-bin/src/table.rs
user: login: Copilot
body: [nitpick] Consider consolidating the consecutive stable sort_by calls into a single comparator to clearly express the multi-level sorting logic.
```suggestion
    // Sort by instructions percent diff (descending), then by status, then by name (ascending).
    const EMPTY: f64 = f64::MIN;
    filtered.sort_by(|a, b| {
        b.instructions
            .percent_diff()
            .unwrap_or(EMPTY)
            .partial_cmp(&a.instructions.percent_diff().unwrap_or(EMPTY))
            .unwrap_or(std::cmp::Ordering::Equal)
            .then_with(|| a.status.cmp(&b.status))
            .then_with(|| a.benchmark.full_name().cmp(&b.benchmark.full_name()))
```
created_at: 2025-05-14T13:49:17Z
author_association: NONE
diff_hunk: @@ -0,0 +1,159 @@
+use crate::fmt::{fmt_human_percent, fmt_human_u64};
+use canbench_rs::{BenchResult, Measurement};
+use std::collections::BTreeMap;
+
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
+pub(crate) struct Entry {
+    pub(crate) status: String,
+    pub(crate) benchmark: Benchmark,
+    pub(crate) instructions: Values,
+    pub(crate) heap_increase: Values,
+    pub(crate) stable_memory_increase: Values,
+}
+
+impl Entry {
+    pub(crate) fn has_scope(&self) -> bool {
+        self.benchmark.scope.is_some()
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
+pub(crate) struct Benchmark {
+    name: String,
+    scope: Option<String>,
+}
+
+impl Benchmark {
+    pub(crate) fn new(name: &str, scope: Option<&str>) -> Self {
+        Self {
+            name: name.to_string(),
+            scope: scope.map(str::to_string),
+        }
+    }
+
+    pub(crate) fn full_name(&self) -> String {
+        self.scope
+            .as_ref()
+            .map(|s| format!("{}::{}", self.name, s))
+            .unwrap_or_else(|| self.name.clone())
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
+pub(crate) enum Change {
+    New,
+    Improved,
+    Regressed,
+    Unchanged,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
+pub(crate) struct Values {
+    new: Option<u64>,
+    old: Option<u64>,
+}
+
+impl Values {
+    pub(crate) fn current(&self) -> Option<u64> {
+        self.new
+    }
+
+    pub(crate) fn abs_delta(&self) -> Option<i64> {
+        Some(self.new? as i64 - self.old? as i64)
+    }
+
+    pub(crate) fn percent_diff(&self) -> Option<f64> {
+        let delta = self.abs_delta()?;
+        let old = self.old?;
+
+        Some(if old == 0 {
+            match delta {
+                d if d < 0 => f64::NEG_INFINITY,
+                d if d > 0 => f64::INFINITY,
+                _ => 0.0,
+            }
+        } else {
+            delta as f64 / old as f64 * 100.0
+        })
+    }
+
+    pub(crate) fn fmt_current(&self) -> String {
+        self.current().map_or_else(String::new, |v| v.to_string())
+    }
+
+    pub(crate) fn fmt_human_current(&self) -> String {
+        self.current().map_or_else(String::new, fmt_human_u64)
+    }
+
+    pub(crate) fn fmt_human_percent(&self) -> String {
+        self.percent_diff()
+            .map_or_else(String::new, fmt_human_percent)
+    }
+
+    pub(crate) fn status(&self, noise_threshold: f64) -> Change {
+        match (self.new, self.old) {
+            (Some(_), Some(_)) => match self.percent_diff() {
+                Some(p) if p.abs() < noise_threshold => Change::Unchanged,
+                Some(p) if p < 0.0 => Change::Improved,
+                Some(_) => Change::Regressed,
+                _ => panic!("Invalid percent diff"),
path: canbench-bin/src/data.rs
user: login: Copilot
body: [nitpick] Consider handling the case when percent_diff() returns None without panicking to improve robustness in production code.
```suggestion
                None => Change::Unchanged, // Handle None case explicitly
```
created_at: 2025-05-14T13:49:17Z
author_association: NONE
diff_hunk: @@ -0,0 +1,131 @@
+use crate::data::{Change, Entry};
+
+pub(crate) fn filter_entries(data: &[Entry], noise_threshold: f64) -> Vec<Entry> {
+    let mut filtered: Vec<Entry> = data
+        .iter()
+        .filter_map(|entry| {
+            let metrics = [
+                &entry.instructions,
+                &entry.heap_increase,
+                &entry.stable_memory_increase,
+            ];
+
+            let is_significant = metrics.iter().any(|v| {
+                matches!(
+                    v.status(noise_threshold),
+                    Change::New | Change::Improved | Change::Regressed
+                )
+            });
+
+            if !is_significant {
+                return None;
+            }
+
+            let mut status = String::new();
+            if entry.status.is_empty() {
+                if metrics
+                    .iter()
+                    .any(|v| v.status(noise_threshold) == Change::Regressed)
+                {
+                    status.push('+');
+                }
+                if metrics
+                    .iter()
+                    .any(|v| v.status(noise_threshold) == Change::Improved)
+                {
+                    if !status.is_empty() {
+                        status.push('/');
+                    }
+                    status.push('-');
+                }
+            } else {
+                status = entry.status.clone();
+            }
+
+            let mut updated = entry.clone();
+            updated.status = status;
+            Some(updated)
+        })
+        .collect();
+
+    // Sort by name, ascending.
+    filtered.sort_by(|a, b| a.benchmark.full_name().cmp(&b.benchmark.full_name()));
+    // Sort by status.
+    filtered.sort_by(|a, b| a.status.cmp(&b.status));
+    // Sort by instructions percent diff, descending.
+    const EMPTY: f64 = f64::MIN;
+    filtered.sort_by(|a, b| {
+        a.instructions
+            .percent_diff()
+            .unwrap_or(EMPTY)
+            .partial_cmp(&b.instructions.percent_diff().unwrap_or(EMPTY))
+            .unwrap_or(std::cmp::Ordering::Equal)
+            .reverse()
+    });
+
+    filtered
+}
+
+pub(crate) fn print_table(data: &[Entry]) {
+    let columns = [
+        "status", "name", "ins", "ins Δ%", "HI", "HI Δ%", "SMI", "SMI Δ%",
path: canbench-bin/src/table.rs
user: login: mraszyk
body: How about HeapInc and StableMemInc?
created_at: 2025-05-14T14:09:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,131 @@
+use crate::data::{Change, Entry};
+
+pub(crate) fn filter_entries(data: &[Entry], noise_threshold: f64) -> Vec<Entry> {
+    let mut filtered: Vec<Entry> = data
+        .iter()
+        .filter_map(|entry| {
+            let metrics = [
+                &entry.instructions,
+                &entry.heap_increase,
+                &entry.stable_memory_increase,
+            ];
+
+            let is_significant = metrics.iter().any(|v| {
+                matches!(
+                    v.status(noise_threshold),
+                    Change::New | Change::Improved | Change::Regressed
+                )
+            });
+
+            if !is_significant {
+                return None;
+            }
+
+            let mut status = String::new();
+            if entry.status.is_empty() {
+                if metrics
+                    .iter()
+                    .any(|v| v.status(noise_threshold) == Change::Regressed)
+                {
+                    status.push('+');
+                }
+                if metrics
+                    .iter()
+                    .any(|v| v.status(noise_threshold) == Change::Improved)
+                {
+                    if !status.is_empty() {
+                        status.push('/');
+                    }
+                    status.push('-');
+                }
+            } else {
+                status = entry.status.clone();
+            }
+
+            let mut updated = entry.clone();
+            updated.status = status;
+            Some(updated)
+        })
+        .collect();
+
+    // Sort by name, ascending.
+    filtered.sort_by(|a, b| a.benchmark.full_name().cmp(&b.benchmark.full_name()));
+    // Sort by status.
+    filtered.sort_by(|a, b| a.status.cmp(&b.status));
+    // Sort by instructions percent diff, descending.
+    const EMPTY: f64 = f64::MIN;
+    filtered.sort_by(|a, b| {
+        a.instructions
+            .percent_diff()
+            .unwrap_or(EMPTY)
+            .partial_cmp(&b.instructions.percent_diff().unwrap_or(EMPTY))
+            .unwrap_or(std::cmp::Ordering::Equal)
+            .reverse()
+    });
+
+    filtered
+}
+
+pub(crate) fn print_table(data: &[Entry]) {
path: canbench-bin/src/table.rs
user: login: mraszyk
body: Is there no library function for that?
created_at: 2025-05-14T14:11:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,131 @@
+use crate::data::{Change, Entry};
+
+pub(crate) fn filter_entries(data: &[Entry], noise_threshold: f64) -> Vec<Entry> {
+    let mut filtered: Vec<Entry> = data
+        .iter()
+        .filter_map(|entry| {
+            let metrics = [
+                &entry.instructions,
+                &entry.heap_increase,
+                &entry.stable_memory_increase,
+            ];
+
+            let is_significant = metrics.iter().any(|v| {
+                matches!(
+                    v.status(noise_threshold),
+                    Change::New | Change::Improved | Change::Regressed
+                )
+            });
+
+            if !is_significant {
+                return None;
+            }
+
+            let mut status = String::new();
+            if entry.status.is_empty() {
+                if metrics
+                    .iter()
+                    .any(|v| v.status(noise_threshold) == Change::Regressed)
+                {
+                    status.push('+');
+                }
+                if metrics
+                    .iter()
+                    .any(|v| v.status(noise_threshold) == Change::Improved)
+                {
+                    if !status.is_empty() {
+                        status.push('/');
+                    }
+                    status.push('-');
+                }
+            } else {
+                status = entry.status.clone();
+            }
+
+            let mut updated = entry.clone();
+            updated.status = status;
+            Some(updated)
+        })
+        .collect();
+
+    // Sort by name, ascending.
+    filtered.sort_by(|a, b| a.benchmark.full_name().cmp(&b.benchmark.full_name()));
+    // Sort by status.
+    filtered.sort_by(|a, b| a.status.cmp(&b.status));
+    // Sort by instructions percent diff, descending.
+    const EMPTY: f64 = f64::MIN;
+    filtered.sort_by(|a, b| {
+        a.instructions
+            .percent_diff()
+            .unwrap_or(EMPTY)
+            .partial_cmp(&b.instructions.percent_diff().unwrap_or(EMPTY))
+            .unwrap_or(std::cmp::Ordering::Equal)
+            .reverse()
+    });
+
+    filtered
+}
+
+pub(crate) fn print_table(data: &[Entry]) {
+    let columns = [
+        "status", "name", "ins", "ins Δ%", "HI", "HI Δ%", "SMI", "SMI Δ%",
path: canbench-bin/src/table.rs
user: login: maksymar
body: I am trying to fit into github comment width, which may not look too bad for canbench repo, but might be in stable-structures, because of long benchmark names. So I'd prefer to keep it as is, especially because short summary does have full names. And also CSV file has full names as well.

![image](https://github.com/user-attachments/assets/8c4ec4ba-ba85-482b-8002-a884300596b6)

created_at: 2025-05-14T14:12:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,131 @@
+use crate::data::{Change, Entry};
+
+pub(crate) fn filter_entries(data: &[Entry], noise_threshold: f64) -> Vec<Entry> {
+    let mut filtered: Vec<Entry> = data
+        .iter()
+        .filter_map(|entry| {
+            let metrics = [
+                &entry.instructions,
+                &entry.heap_increase,
+                &entry.stable_memory_increase,
+            ];
+
+            let is_significant = metrics.iter().any(|v| {
+                matches!(
+                    v.status(noise_threshold),
+                    Change::New | Change::Improved | Change::Regressed
+                )
+            });
+
+            if !is_significant {
+                return None;
+            }
+
+            let mut status = String::new();
+            if entry.status.is_empty() {
+                if metrics
+                    .iter()
+                    .any(|v| v.status(noise_threshold) == Change::Regressed)
+                {
+                    status.push('+');
+                }
+                if metrics
+                    .iter()
+                    .any(|v| v.status(noise_threshold) == Change::Improved)
+                {
+                    if !status.is_empty() {
+                        status.push('/');
+                    }
+                    status.push('-');
+                }
+            } else {
+                status = entry.status.clone();
+            }
+
+            let mut updated = entry.clone();
+            updated.status = status;
+            Some(updated)
+        })
+        .collect();
+
+    // Sort by name, ascending.
+    filtered.sort_by(|a, b| a.benchmark.full_name().cmp(&b.benchmark.full_name()));
+    // Sort by status.
+    filtered.sort_by(|a, b| a.status.cmp(&b.status));
+    // Sort by instructions percent diff, descending.
+    const EMPTY: f64 = f64::MIN;
+    filtered.sort_by(|a, b| {
+        a.instructions
+            .percent_diff()
+            .unwrap_or(EMPTY)
+            .partial_cmp(&b.instructions.percent_diff().unwrap_or(EMPTY))
+            .unwrap_or(std::cmp::Ordering::Equal)
+            .reverse()
+    });
+
+    filtered
+}
+
+pub(crate) fn print_table(data: &[Entry]) {
path: canbench-bin/src/table.rs
user: login: maksymar
body: I needed a custom align logic on some columns so it felt like writing my own function would be simpler than exploring the alternatives. I also tried to use red/green ball emojis as status indicators but it turned out they occupy 2 spaces vs 1 and it's hard to calculate their width correctly (tried some crates) and decided to switch back to simpler solutions.
created_at: 2025-05-14T14:15:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,165 @@
+use crate::fmt::{fmt_human_percent, fmt_human_u64};
+use canbench_rs::{BenchResult, Measurement};
+use std::collections::BTreeMap;
+
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
+pub(crate) struct Entry {
+    pub(crate) status: String,
+    pub(crate) benchmark: Benchmark,
+    pub(crate) instructions: Values,
+    pub(crate) heap_increase: Values,
+    pub(crate) stable_memory_increase: Values,
+}
+
+impl Entry {
+    pub(crate) fn has_scope(&self) -> bool {
+        self.benchmark.scope.is_some()
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
+pub(crate) struct Benchmark {
+    name: String,
+    scope: Option<String>,
+}
+
+impl Benchmark {
+    pub(crate) fn new(name: &str, scope: Option<&str>) -> Self {
+        Self {
+            name: name.to_string(),
+            scope: scope.map(str::to_string),
+        }
+    }
+
+    pub(crate) fn full_name(&self) -> String {
+        self.scope
+            .as_ref()
+            .map(|s| format!("{}::{}", self.name, s))
+            .unwrap_or_else(|| self.name.clone())
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
+pub(crate) enum Change {
+    New,
+    Improved,
+    Regressed,
+    Unchanged,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
+pub(crate) struct Values {
+    new: Option<u64>,
+    old: Option<u64>,
+}
+
+impl Values {
+    pub(crate) fn current(&self) -> Option<u64> {
+        self.new
+    }
+
+    pub(crate) fn abs_delta(&self) -> Option<i64> {
+        Some(self.new? as i64 - self.old? as i64)
+    }
+
+    pub(crate) fn percent_diff(&self) -> Option<f64> {
+        let delta = self.abs_delta()?;
+        let old = self.old?;
+
+        Some(if old == 0 {
+            match delta {
+                d if d < 0 => f64::NEG_INFINITY,
+                d if d > 0 => f64::INFINITY,
+                _ => 0.0,
+            }
+        } else {
+            delta as f64 / old as f64 * 100.0
+        })
+    }
+
+    pub(crate) fn fmt_current(&self) -> String {
+        self.current().map_or_else(String::new, |v| v.to_string())
+    }
+
+    pub(crate) fn fmt_human_current(&self) -> String {
+        self.current().map_or_else(String::new, fmt_human_u64)
+    }
+
+    pub(crate) fn fmt_human_percent(&self) -> String {
+        self.percent_diff()
+            .map_or_else(String::new, fmt_human_percent)
+    }
+
+    pub(crate) fn status(&self, noise_threshold: f64) -> Change {
+        match (self.new, self.old) {
+            (Some(_), Some(_)) => match self.percent_diff() {
+                Some(p) if p.abs() < noise_threshold => Change::Unchanged,
+                Some(p) if p < 0.0 => Change::Improved,
+                Some(_) => Change::Regressed,
+                None => Change::Unchanged,
+            },
+            (Some(_), None) => Change::New,
+            (None, Some(_)) => {
+                // This is actually a removed benchmark
+                // but we don't track it at the moment,
+                // so we treat it as unchanged.
+                Change::Unchanged
+            }
+            (None, None) => Change::Unchanged,
+        }
+    }
+}
+
+pub(crate) fn extract(
+    new_results: &BTreeMap<String, BenchResult>,
+    old_results: &BTreeMap<String, BenchResult>,
+) -> Vec<Entry> {
+    let mut results = Vec::new();
+
+    for (name, new_bench) in new_results {
+        let old_bench = old_results.get(name);
+
+        // Process total
+        let benchmark = Benchmark::new(name, None);
+        results.push(build_entry(
+            if old_bench.is_none() { "new" } else { "" }.to_string(),
path: canbench-bin/src/data.rs
user: login: mraszyk
body: This seems derived from the last arg => maybe can be omitted as a separate arg?
created_at: 2025-05-14T14:19:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,165 @@
+use crate::fmt::{fmt_human_percent, fmt_human_u64};
+use canbench_rs::{BenchResult, Measurement};
+use std::collections::BTreeMap;
+
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
+pub(crate) struct Entry {
+    pub(crate) status: String,
+    pub(crate) benchmark: Benchmark,
+    pub(crate) instructions: Values,
+    pub(crate) heap_increase: Values,
+    pub(crate) stable_memory_increase: Values,
+}
+
+impl Entry {
+    pub(crate) fn has_scope(&self) -> bool {
+        self.benchmark.scope.is_some()
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
+pub(crate) struct Benchmark {
+    name: String,
+    scope: Option<String>,
+}
+
+impl Benchmark {
+    pub(crate) fn new(name: &str, scope: Option<&str>) -> Self {
+        Self {
+            name: name.to_string(),
+            scope: scope.map(str::to_string),
+        }
+    }
+
+    pub(crate) fn full_name(&self) -> String {
+        self.scope
+            .as_ref()
+            .map(|s| format!("{}::{}", self.name, s))
+            .unwrap_or_else(|| self.name.clone())
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
+pub(crate) enum Change {
+    New,
+    Improved,
+    Regressed,
+    Unchanged,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
+pub(crate) struct Values {
+    new: Option<u64>,
+    old: Option<u64>,
+}
+
+impl Values {
+    pub(crate) fn current(&self) -> Option<u64> {
+        self.new
+    }
+
+    pub(crate) fn abs_delta(&self) -> Option<i64> {
+        Some(self.new? as i64 - self.old? as i64)
+    }
+
+    pub(crate) fn percent_diff(&self) -> Option<f64> {
+        let delta = self.abs_delta()?;
+        let old = self.old?;
+
+        Some(if old == 0 {
+            match delta {
+                d if d < 0 => f64::NEG_INFINITY,
+                d if d > 0 => f64::INFINITY,
+                _ => 0.0,
+            }
+        } else {
+            delta as f64 / old as f64 * 100.0
+        })
+    }
+
+    pub(crate) fn fmt_current(&self) -> String {
+        self.current().map_or_else(String::new, |v| v.to_string())
+    }
+
+    pub(crate) fn fmt_human_current(&self) -> String {
+        self.current().map_or_else(String::new, fmt_human_u64)
+    }
+
+    pub(crate) fn fmt_human_percent(&self) -> String {
+        self.percent_diff()
+            .map_or_else(String::new, fmt_human_percent)
+    }
+
+    pub(crate) fn status(&self, noise_threshold: f64) -> Change {
+        match (self.new, self.old) {
+            (Some(_), Some(_)) => match self.percent_diff() {
+                Some(p) if p.abs() < noise_threshold => Change::Unchanged,
+                Some(p) if p < 0.0 => Change::Improved,
+                Some(_) => Change::Regressed,
+                None => Change::Unchanged,
+            },
+            (Some(_), None) => Change::New,
+            (None, Some(_)) => {
+                // This is actually a removed benchmark
+                // but we don't track it at the moment,
+                // so we treat it as unchanged.
+                Change::Unchanged
+            }
+            (None, None) => Change::Unchanged,
+        }
+    }
+}
+
+pub(crate) fn extract(
+    new_results: &BTreeMap<String, BenchResult>,
+    old_results: &BTreeMap<String, BenchResult>,
+) -> Vec<Entry> {
+    let mut results = Vec::new();
+
+    for (name, new_bench) in new_results {
+        let old_bench = old_results.get(name);
+
+        // Process total
+        let benchmark = Benchmark::new(name, None);
+        results.push(build_entry(
+            if old_bench.is_none() { "new" } else { "" }.to_string(),
path: canbench-bin/src/data.rs
user: login: maksymar
body: I'd like to pass it separately as a string because it can be later extended to also track 'removed' benchmarks.

I tried to do it in current PR but found out that current testing setup uses the same canbench_results.yml file for all the benchmarks, so it detects all unused data as deleted benchmarks. Not a huge deal, but probably can be done as a separate feature, because it should also be reflected in streaming processed results.

Therefore it seems like a good idea to keep it as a separate argument for the ability to extend in the future.
created_at: 2025-05-14T15:17:11Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-05-13T13:57:27Z
body: # `canbench` 🏋 (dir: examples/fibonacci) aa9bc02bbb2972ad9f1ae075301a06c2d5657818 2025-05-14 14:03:15 UTC
✅ `examples/fibonacci/canbench_results.yml` is up to date
📦 `canbench_results_benchmark-fibonacci-example.csv` available in [artifacts](https://github.com/dfinity/canbench/actions/runs/15022584712)

```
---------------------------------------------------

Summary:
  instructions:
    status:   No significant changes detected 👍
    counts:   [total 5 | new 0 | improved 0 | regressed 0 | unchanged 5]
    change:   [min 0 | med 0 | max 0]
    change %: [min 0.00% | med 0.00% | max 0.00%]

  heap_increase:
    status:   No significant changes detected 👍
    counts:   [total 5 | new 0 | improved 0 | regressed 0 | unchanged 5]
    change:   [min 0 | med 0 | max 0]
    change %: [min 0.00% | med 0.00% | max 0.00%]

  stable_memory_increase:
    status:   No significant changes detected 👍
    counts:   [total 5 | new 0 | improved 0 | regressed 0 | unchanged 5]
    change:   [min 0 | med 0 | max 0]
    change %: [min 0.00% | med 0.00% | max 0.00%]

---------------------------------------------------
Successfully saved CSV results to canbench_results.csv
```
<!-- thollander/actions-comment-pull-request "canbench_result_benchmark-fibonacci-example" -->
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-13T13:57:29Z
body: # `canbench` 🏋 (dir: examples/btreemap_vs_hashmap) aa9bc02bbb2972ad9f1ae075301a06c2d5657818 2025-05-14 14:03:13 UTC
✅ `examples/btreemap_vs_hashmap/canbench_results.yml` is up to date
📦 `canbench_results_benchmark-btreemap-vs-hashmap-example.csv` available in [artifacts](https://github.com/dfinity/canbench/actions/runs/15022584712)

```
---------------------------------------------------

Summary:
  instructions:
    status:   No significant changes detected 👍
    counts:   [total 3 | new 0 | improved 0 | regressed 0 | unchanged 3]
    change:   [min -6.45K | med -575 | max +75.97K]
    change %: [min -0.00% | med -0.00% | max 0.00%]

  heap_increase:
    status:   No significant changes detected 👍
    counts:   [total 3 | new 0 | improved 0 | regressed 0 | unchanged 3]
    change:   [min -1 | med 0 | max 0]
    change %: [min -0.11% | med 0.00% | max 0.00%]

  stable_memory_increase:
    status:   No significant changes detected 👍
    counts:   [total 3 | new 0 | improved 0 | regressed 0 | unchanged 3]
    change:   [min 0 | med 0 | max 0]
    change %: [min 0.00% | med 0.00% | max 0.00%]

---------------------------------------------------
Successfully saved CSV results to canbench_results.csv
```
<!-- thollander/actions-comment-pull-request "canbench_result_benchmark-btreemap-vs-hashmap-example" -->
author_association: NONE