Skip to main content

data_beans/
qc.rs

1use crate::sparse_data_visitors::*;
2use crate::sparse_io::*;
3use crate::sparse_io_vector::*;
4
5use indicatif::ParallelProgressIterator;
6use legume_numeric::matrix::sparse_stat::{SparseColumnRunningStatistics, SparseRunningStatistics};
7use legume_numeric::matrix::traits::RunningStatOps;
8use legume_numeric::matrix::utils::partition_by_membership;
9use log::warn;
10use rayon::prelude::*;
11use std::sync::{Arc, Mutex};
12
13// The binary compiles its own copy of this module and reads only part of
14// the axis.
15#[allow(dead_code)]
16pub mod axis;
17pub use axis::Axis;
18
19use rustc_hash::FxHashMap as HashMap;
20
21#[derive(Clone)]
22pub struct SqueezeCutoffs {
23    pub row: usize,
24    pub column: usize,
25}
26
27/// Whether a row or column with `nnz` non-zeros falls below `cutoff` and is
28/// squeezed out. Every preview of a cutoff counts with this rule, so what it
29/// reports matches what [`squeeze_by_nnz`] removes.
30pub fn below_nnz_cutoff(nnz: f32, cutoff: usize) -> bool {
31    (nnz as usize) < cutoff
32}
33
34/// squeeze out rows and columns with excessive zero values
35pub fn squeeze_by_nnz(
36    data: &dyn SparseIo<IndexIter = Vec<usize>>,
37    cutoffs: SqueezeCutoffs,
38    block_size: Option<usize>,
39    preload: bool,
40) -> anyhow::Result<()> {
41    let col_stat = collect_column_stat(data, block_size)?;
42    let row_stat = collect_row_stat(data, block_size)?;
43
44    let file = data.get_backend_file_name();
45    let backend = data.backend_type();
46
47    let mut data = open_sparse_matrix(file, &backend)?;
48    if preload {
49        data.preload_columns()?;
50    }
51
52    fn nnz_index(nnz: &[f32], cutoff: usize) -> Option<Vec<usize>> {
53        let ret: Vec<usize> = nnz
54            .iter()
55            .enumerate()
56            .filter(|&(_, &x)| !below_nnz_cutoff(x, cutoff))
57            .map(|(i, _)| i)
58            .collect();
59
60        (!ret.is_empty()).then_some(ret)
61    }
62
63    let row_nnz_vec = row_stat.count_positives();
64    let col_nnz_vec = col_stat.count_positives();
65    let row_idx = nnz_index(&row_nnz_vec, cutoffs.row);
66    let col_idx = nnz_index(&col_nnz_vec, cutoffs.column);
67
68    if row_idx.is_none() {
69        warn!(
70            "No rows can be kept with this cutoff {}!\n\
71	     \n\
72	     We will stop squeezing on the rows.\n\
73	     \n",
74            cutoffs.row
75        );
76    }
77
78    if col_idx.is_none() {
79        warn!(
80            "No columns can be kept with this cutoff {}!\n\
81	     \n\
82	     We will stop squeezing on the columns.\n\
83	     \n",
84            cutoffs.column
85        );
86    }
87
88    data.subset_columns_rows(col_idx.as_ref(), row_idx.as_ref())
89}
90
91/// collect row-wise sufficient statistics for Q/C
92/// * `data` - `SparseIoVec` across many data matrices
93/// * `block_size` - a block size for each parallelized job
94pub fn collect_row_stat_across_vec(
95    data: &SparseIoVec,
96    block_size: Option<usize>,
97) -> anyhow::Result<SparseRunningStatistics<f32>> {
98    let mut row_stat = SparseRunningStatistics::new(data.num_rows());
99    data.visit_columns_by_block(
100        &row_stat_vec_visitor,
101        &EmptyArgs {},
102        &mut row_stat,
103        block_size,
104    )?;
105    Ok(row_stat)
106}
107
108/// collect row statistics for each group of columns
109/// * `data` - `SparseIo`
110/// * `column_membership` - a hashmap assign columns to groups
111/// * `block_size` - a block size for each parallelized job
112#[allow(clippy::type_complexity)]
113pub fn collect_stratified_row_stat_across_vec(
114    data: &SparseIoVec,
115    column_membership: &HashMap<Box<str>, Box<str>>,
116    block_size: Option<usize>,
117) -> anyhow::Result<(Vec<Box<str>>, Vec<SparseRunningStatistics<f32>>)> {
118    let column_names = data.column_names()?;
119    let default = "".to_string().into_boxed_str();
120    let membership = column_names
121        .into_iter()
122        .map(|k| column_membership.get(&k).unwrap_or(&default).clone())
123        .collect::<Vec<_>>();
124
125    let partitions = partition_by_membership(&membership, None);
126    let mut group_names = Vec::with_capacity(partitions.len());
127    let mut group_stats = Vec::with_capacity(partitions.len());
128    let num_features = data.num_rows();
129
130    for (k, cols) in partitions {
131        let jobs = create_jobs(cols.len(), num_features, block_size);
132        let mut row_stat = SparseRunningStatistics::new(data.num_rows());
133        let arc_stat = Arc::new(Mutex::new(&mut row_stat));
134
135        jobs.par_iter()
136            .progress_with(styled_progress_bar(jobs.len() as u64, "blocks"))
137            .for_each(|&(lb, ub)| {
138                let cols_sub = cols[lb..ub].iter().cloned();
139                let csc = data
140                    .read_columns_csc(cols_sub)
141                    .expect("failed to read data");
142                let mut stat = arc_stat.lock().expect("failed to lock row_stat");
143                stat.add_csc(&csc);
144            });
145
146        group_names.push(k);
147        group_stats.push(row_stat);
148    }
149
150    Ok((group_names, group_stats))
151}
152
153/// collect row-wise sufficient statistics for Q/C
154/// * `data` - `SparseIo`
155/// * `block_size` - a block size for each parallelized job
156pub fn collect_row_stat(
157    data: &dyn SparseIo<IndexIter = Vec<usize>>,
158    block_size: Option<usize>,
159) -> anyhow::Result<SparseRunningStatistics<f32>> {
160    let nrows = data.num_rows().unwrap_or(0);
161    let mut row_stat = SparseRunningStatistics::new(nrows);
162    let arc_stat = Arc::new(Mutex::new(&mut row_stat));
163
164    let jobs = create_jobs(data.num_columns().unwrap_or(0), nrows, block_size);
165
166    jobs.par_iter()
167        .progress_with(styled_progress_bar(jobs.len() as u64, "blocks"))
168        .for_each(|&(lb, ub)| {
169            let csc = data
170                .read_columns_csc((lb..ub).collect())
171                .expect("failed to read data");
172            let mut stat = arc_stat.lock().expect("failed to lock row_stat");
173            stat.add_csc(&csc);
174        });
175
176    Ok(row_stat)
177}
178
179/// collect column-wise sufficient statistics for Q/C
180/// * `data` - `SparseIoVec` across many data matrices
181/// * `select_rows` - selected row indices
182/// * `block_size` - a block size for each parallelized job
183pub fn collect_column_stat_across_vec(
184    data: &SparseIoVec,
185    select_rows: Option<&[usize]>,
186    block_size: Option<usize>,
187) -> anyhow::Result<SparseColumnRunningStatistics<f32>> {
188    let ncols = data.num_columns();
189    let nrows_total = data.num_rows();
190
191    let row_mask: Option<Vec<bool>> = select_rows.map(|sel| {
192        let mut m = vec![false; nrows_total];
193        for &r in sel {
194            if r < nrows_total {
195                m[r] = true;
196            }
197        }
198        m
199    });
200    let nrows_denom = row_mask
201        .as_ref()
202        .map(|m| m.iter().filter(|x| **x).count())
203        .unwrap_or(nrows_total);
204
205    let mut col_stat = SparseColumnRunningStatistics::<f32>::new(ncols, nrows_denom);
206    data.visit_columns_by_block(&col_stat_visitor, &row_mask, &mut col_stat, block_size)?;
207    Ok(col_stat)
208}
209
210/// collect column-wise sufficient statistics for Q/C
211/// * `data` - `SparseIo`
212/// * `block_size` - a block size for each parallelized job
213pub fn collect_column_stat(
214    data: &dyn SparseIo<IndexIter = Vec<usize>>,
215    block_size: Option<usize>,
216) -> anyhow::Result<SparseColumnRunningStatistics<f32>> {
217    let ncols = data.num_columns().unwrap_or(0);
218    let nrows = data.num_rows().unwrap_or(0);
219    let mut col_stat = SparseColumnRunningStatistics::<f32>::new(ncols, nrows);
220    let arc_stat = Arc::new(Mutex::new(&mut col_stat));
221
222    let jobs = create_jobs(ncols, nrows, block_size);
223
224    jobs.par_iter()
225        .progress_with(styled_progress_bar(jobs.len() as u64, "blocks"))
226        .for_each(|&(lb, ub)| {
227            let csc = data
228                .read_columns_csc((lb..ub).collect())
229                .expect("failed to read data");
230            let mut stat = arc_stat.lock().expect("failed to lock col_stat");
231            stat.add_csc(&csc, lb);
232        });
233
234    Ok(col_stat)
235}
236
237struct EmptyArgs {}
238
239fn row_stat_vec_visitor(
240    job: (usize, usize),
241    data: &SparseIoVec,
242    _: &EmptyArgs,
243    arc_stat: Arc<Mutex<&mut SparseRunningStatistics<f32>>>,
244) -> anyhow::Result<()> {
245    let (lb, ub) = job;
246    let csc = data.read_columns_csc(lb..ub)?;
247
248    let mut stat = arc_stat.lock().expect("failed to lock row_stat");
249    stat.add_csc(&csc);
250    Ok(())
251}
252
253fn col_stat_visitor(
254    job: (usize, usize),
255    data: &SparseIoVec,
256    row_mask: &Option<Vec<bool>>,
257    arc_stat: Arc<Mutex<&mut SparseColumnRunningStatistics<f32>>>,
258) -> anyhow::Result<()> {
259    let (lb, ub) = job;
260    let csc = data.read_columns_csc(lb..ub)?;
261    let mut stat = arc_stat.lock().expect("failed to lock col_stat");
262    match row_mask {
263        Some(mask) => stat.add_csc_masked(&csc, lb, mask),
264        None => stat.add_csc(&csc, lb),
265    }
266    Ok(())
267}
268
269//////////////////////////////////////////////////////////////////////////////////
270// Automatic nnz-cutoff selection + ASCII histogram (shared by `squeeze` and by //
271// callers that want cell-calling on a per-column nnz vector).                   //
272//////////////////////////////////////////////////////////////////////////////////
273
274/// Bin width of the trough search, in natural-log units (~5% per bin).
275const TROUGH_BIN: f64 = 0.05;
276/// Gaussian smoothing of the binned density, in bins.
277const TROUGH_SMOOTH_BINS: f64 = 3.0;
278/// A cut needs the trough at most this fraction of the lower of its two peaks.
279const TROUGH_MAX_DEPTH: f64 = 0.25;
280/// Ambient and cell peaks sit at least a decade apart; closer modes (a doublet
281/// bump, a low-complexity cell type) are not an empty↔cell boundary.
282const TROUGH_MIN_PEAK_RATIO: f64 = 10.0;
283/// Each side of a cut must hold at least this many columns (or 0.1% of all).
284const TROUGH_MIN_SIDE: usize = 20;
285
286/// Suggest an nnz cutoff at the **deepest trough** of the nnz distribution,
287/// the gap between the ambient (empty barcode) peak and the cell peak.
288/// Columns with `nnz >= cutoff` are kept.
289///
290/// The density lives on the log axis, where ambient and cells form two
291/// peaks decades apart; in linear units the cell peak is spread so thin
292/// that the trough in front of it vanishes. The bins, though, come from
293/// the actual counts: each integer count `v` spreads its mass over its own
294/// interval `[v - 1/2, v + 1/2)` mapped to `ln(1 + ·)`, so small counts,
295/// whose log spacing exceeds a bin, leave no empty bins to pass for troughs.
296///
297/// A cut is proposed only when the smoothed density at the trough is at most
298/// [`TROUGH_MAX_DEPTH`] of the lower of the two peaks it separates, the
299/// peaks are at least [`TROUGH_MIN_PEAK_RATIO`] apart, and each side holds
300/// enough columns. Unimodal data — already-called cells included — gets
301/// `None`, whatever its tails look like. Deterministic, no RNG.
302pub fn suggest_nnz_cutoff(nnz: &[f32]) -> Option<usize> {
303    let n = nnz.len();
304    let min_side = TROUGH_MIN_SIDE.max(n / 1000);
305    if n < 2 * min_side {
306        return None;
307    }
308
309    let mut vals: Vec<u64> = nnz.iter().map(|&x| x.max(0.0).round() as u64).collect();
310    vals.sort_unstable();
311    let (vmin, vmax) = (vals[0], vals[n - 1]);
312    if vmin == vmax {
313        return None;
314    }
315
316    // Count `v`'s interval `[v - 1/2, v + 1/2)` on the `ln(1 + ·)` axis.
317    let edge = |v: u64, half: f64| (v as f64 + 1.0 + half).ln();
318    let lo = edge(vmin, -0.5);
319    let nbins = ((edge(vmax, 0.5) - lo) / TROUGH_BIN).ceil() as usize;
320    let mut hist = vec![0.0_f64; nbins];
321    let mut i = 0;
322    while i < n {
323        let v = vals[i];
324        let mut j = i;
325        while j < n && vals[j] == v {
326            j += 1;
327        }
328        let a = (edge(v, -0.5) - lo) / TROUGH_BIN;
329        let b = (edge(v, 0.5) - lo) / TROUGH_BIN;
330        let mass = (j - i) as f64 / (b - a);
331        for (k, h) in hist
332            .iter_mut()
333            .enumerate()
334            .take((b.ceil() as usize).min(nbins))
335            .skip(a.floor() as usize)
336        {
337            let overlap = b.min(k as f64 + 1.0) - a.max(k as f64);
338            if overlap > 0.0 {
339                *h += mass * overlap;
340            }
341        }
342        i = j;
343    }
344
345    // Gaussian smoothing (finite ±3σ kernel, so a real gap stays exactly 0).
346    let radius = (3.0 * TROUGH_SMOOTH_BINS).ceil() as isize;
347    let kernel: Vec<f64> = (-radius..=radius)
348        .map(|d| (-0.5 * (d as f64 / TROUGH_SMOOTH_BINS).powi(2)).exp())
349        .collect();
350    let smooth: Vec<f64> = (0..nbins as isize)
351        .map(|k| {
352            (-radius..=radius)
353                .filter_map(|d| {
354                    let t = k + d;
355                    (0..nbins as isize)
356                        .contains(&t)
357                        .then(|| hist[t as usize] * kernel[(d + radius) as usize])
358                })
359                .sum()
360        })
361        .collect();
362
363    // Mass left of each bin, and the running peaks from either end.
364    let mut below = vec![0.0_f64; nbins + 1];
365    for k in 0..nbins {
366        below[k + 1] = below[k] + hist[k];
367    }
368    let mut left_peak = vec![(0.0_f64, 0usize); nbins];
369    for k in 0..nbins {
370        let prev = if k > 0 { left_peak[k - 1] } else { (-1.0, 0) };
371        left_peak[k] = if smooth[k] > prev.0 {
372            (smooth[k], k)
373        } else {
374            prev
375        };
376    }
377    let mut right_peak = vec![(0.0_f64, 0usize); nbins];
378    for k in (0..nbins).rev() {
379        let next = if k + 1 < nbins {
380            right_peak[k + 1]
381        } else {
382            (-1.0, k)
383        };
384        right_peak[k] = if smooth[k] >= next.0 {
385            (smooth[k], k)
386        } else {
387            next
388        };
389    }
390
391    let min_bins_apart = TROUGH_MIN_PEAK_RATIO.ln() / TROUGH_BIN;
392    let total = below[nbins];
393    let mut best: Option<(f64, usize, usize)> = None; // (depth, first, last) of the deepest run
394    for t in 1..nbins.saturating_sub(1) {
395        let (l_mass, r_mass) = (below[t], total - below[t + 1]);
396        if l_mass < min_side as f64 || r_mass < min_side as f64 {
397            continue;
398        }
399        let ((l_h, l_k), (r_h, r_k)) = (left_peak[t - 1], right_peak[t + 1]);
400        if ((r_k - l_k) as f64) < min_bins_apart || l_h <= 0.0 || r_h <= 0.0 {
401            continue;
402        }
403        let depth = smooth[t] / l_h.min(r_h);
404        match best {
405            Some((d, first, last)) if depth == d && last + 1 == t => best = Some((d, first, t)),
406            Some((d, _, _)) if depth >= d => {}
407            _ => best = Some((depth, t, t)),
408        }
409    }
410
411    let Some((depth, first, last)) = best else {
412        log::info!("nnz cell-calling: no two peaks a decade apart → unimodal, no cutoff");
413        return None;
414    };
415    // Middle of the deepest run (an exact-zero gap is a run, not a point).
416    let x = lo + ((first + last) as f64 / 2.0 + 0.5) * TROUGH_BIN;
417    let cutoff = (x.exp() - 1.0).ceil().max(1.0) as usize;
418    let favors_cut = depth <= TROUGH_MAX_DEPTH;
419    log::info!(
420        "nnz cell-calling: deepest trough at nnz {} (depth {:.3}) → {}",
421        cutoff,
422        depth,
423        if favors_cut {
424            format!("bimodal, cutoff at nnz {cutoff}")
425        } else {
426            "unimodal, no cutoff".to_string()
427        }
428    );
429    favors_cut.then_some(cutoff)
430}
431
432/// `part` as a percentage of `total` (0 when there is nothing).
433pub fn pct(part: usize, total: usize) -> f64 {
434    if total > 0 {
435        100.0 * part as f64 / total as f64
436    } else {
437        0.0
438    }
439}
440
441/// Median of an ascending slice (0 when empty).
442pub fn median_of_sorted(sorted: &[f32]) -> f32 {
443    let n = sorted.len();
444    match n {
445        0 => 0.0,
446        _ if n.is_multiple_of(2) => (sorted[n / 2 - 1] + sorted[n / 2]) / 2.0,
447        _ => sorted[n / 2],
448    }
449}
450
451/// One bin of a printed histogram that holds values, with the real range
452/// of the values that fell into it.
453struct HistBin {
454    val_min: f32,
455    val_max: f32,
456    count: usize,
457    is_cutoff: bool,
458}
459
460/// Bins of a printed histogram across its values' range.
461const PRINTED_BINS: usize = 40;
462
463/// A histogram of `values` on the log [`Axis`] every histogram shares, its
464/// bins that hold values with their real ranges. Works on any non-negative
465/// statistic (nnz, sum, mean, sd); the ranges stay exact `f32` so
466/// count-like stats still print as integers.
467fn create_log_histogram(values: &[f32], cutoff: usize) -> Vec<HistBin> {
468    let axis = Axis::fit(values, true).including(cutoff as f64);
469    let mut bins: Vec<(usize, f32, f32)> =
470        vec![(0, f32::INFINITY, f32::NEG_INFINITY); PRINTED_BINS];
471    for &val in values.iter().filter(|v| v.is_finite()) {
472        let b = &mut bins[axis.bin(val as f64, PRINTED_BINS)];
473        b.0 += 1;
474        b.1 = b.1.min(val);
475        b.2 = b.2.max(val);
476    }
477    // Mark the first bin at or above the cutoff that holds values, so the
478    // arrow always renders. With no cutoff (cutoff == 0, e.g. the
479    // `histogram` command) nothing is marked.
480    let cutoff_bin = (cutoff > 0)
481        .then(|| {
482            let at = axis.bin(cutoff as f64, PRINTED_BINS);
483            (at..PRINTED_BINS).find(|&k| bins[k].0 > 0)
484        })
485        .flatten();
486    bins.into_iter()
487        .enumerate()
488        .filter(|(_, b)| b.0 > 0)
489        .map(|(k, (count, val_min, val_max))| HistBin {
490            val_min,
491            val_max,
492            count,
493            is_cutoff: Some(k) == cutoff_bin,
494        })
495        .collect()
496}
497
498/// Format a statistic value compactly: whole numbers (nnz, integer counts)
499/// print without a decimal point; fractional values (mean, sd) get
500/// `decimals` places.
501pub fn fmt_stat(v: f32, decimals: usize) -> String {
502    if v.fract() == 0.0 {
503        (v as i64).to_string()
504    } else {
505        format!("{:.*}", decimals, v)
506    }
507}
508
509/// A value as a printed histogram's range shows it: whole numbers as they
510/// are, others to three significant digits (0.000412, 0.0731, 2.45).
511fn fmt_sig(v: f32) -> String {
512    if v.fract() == 0.0 {
513        return (v as i64).to_string();
514    }
515    let places = (2 - (v.abs() as f64).log10().floor() as i32).max(0) as usize;
516    format!("{:.*}", places, v)
517}
518
519/// Print summary statistics + an ASCII log-binned histogram of a per-row or
520/// per-column statistic vector (`metric` names it, e.g. "nnz", "sum", "mean").
521/// A non-zero `cutoff` marks the cutoff bin and reports how much it removes;
522/// an optional `suggested` value reports the trough suggestion.
523///
524/// Used by `data-beans histogram`, whose result it is; `qc --qc-histogram`
525/// prints the same on stderr ([`eprint_nnz_summary`]).
526pub fn print_nnz_summary(
527    label: &str,
528    metric: &str,
529    values: &[f32],
530    cutoff: usize,
531    suggested: Option<usize>,
532) {
533    let _ = write_nnz_summary(
534        &mut std::io::stdout().lock(),
535        label,
536        metric,
537        values,
538        cutoff,
539        suggested,
540    );
541}
542
543/// [`print_nnz_summary`] on stderr: for a histogram that is a diagnostic
544/// beside a command's result (`qc --qc-histogram`, senna's and pinto's QC),
545/// not the result itself, so stdout carries only the result.
546pub fn eprint_nnz_summary(
547    label: &str,
548    metric: &str,
549    values: &[f32],
550    cutoff: usize,
551    suggested: Option<usize>,
552) {
553    let _ = write_nnz_summary(
554        &mut std::io::stderr().lock(),
555        label,
556        metric,
557        values,
558        cutoff,
559        suggested,
560    );
561}
562
563/// [`print_nnz_summary`] to `out`.
564pub fn write_nnz_summary(
565    out: &mut impl std::io::Write,
566    label: &str,
567    metric: &str,
568    values: &[f32],
569    cutoff: usize,
570    suggested: Option<usize>,
571) -> std::io::Result<()> {
572    const MAX_BAR_WIDTH: usize = 50; // Maximum width for histogram bars
573
574    let total = values.len();
575    let below_cutoff = values
576        .iter()
577        .filter(|&&x| below_nnz_cutoff(x, cutoff))
578        .count();
579    let pct_removed = pct(below_cutoff, total);
580
581    // Calculate basic statistics
582    let min = values.iter().copied().fold(f32::INFINITY, f32::min);
583    let max = values.iter().copied().fold(f32::NEG_INFINITY, f32::max);
584    let sum: f32 = values.iter().sum();
585    let mean = if total > 0 { sum / total as f32 } else { 0.0 };
586
587    let mut sorted = values.to_vec();
588    sorted.sort_unstable_by(f32::total_cmp);
589    let median = median_of_sorted(&sorted);
590
591    writeln!(out, "{} {} distribution:", label, metric)?;
592    writeln!(out, "  Total: {}", total)?;
593    writeln!(
594        out,
595        "  Min: {}, Max: {}, Mean: {:.2}, Median: {:.2}",
596        fmt_stat(min, 2),
597        fmt_stat(max, 2),
598        mean,
599        median
600    )?;
601    if cutoff > 0 {
602        writeln!(
603            out,
604            "  Cutoff: {} (removes {} / {} = {:.2}%)",
605            cutoff, below_cutoff, total, pct_removed
606        )?;
607    }
608    if let Some(s) = suggested {
609        let below_s = values.iter().filter(|&&x| below_nnz_cutoff(x, s)).count();
610        let pct_s = pct(below_s, total);
611        writeln!(
612            out,
613            "  Suggested cutoff (histogram trough of {}): {} (would remove {} / {} = {:.2}%)",
614            metric, s, below_s, total, pct_s
615        )?;
616    }
617
618    // Log-spaced bins, tracking the real value range per bin
619    let hist = create_log_histogram(values, cutoff);
620
621    // Scale bar width on log10(count+1) so a few outlier bins don't flatten the rest
622    let max_log_count = hist
623        .iter()
624        .map(|b| ((b.count as f64) + 1.0).log10())
625        .fold(0.0_f64, f64::max)
626        .max(1e-9);
627
628    writeln!(
629        out,
630        "  Histogram (x: {m} in log-spaced bins, each its range; bar: log10(count+1)):",
631        m = metric
632    )?;
633    for b in hist {
634        let marker = if b.is_cutoff { " <-- CUTOFF" } else { "" };
635        let log_count = ((b.count as f64) + 1.0).log10();
636        let bar_width = ((log_count / max_log_count) * MAX_BAR_WIDTH as f64).round() as usize;
637        let bar_width = if b.count > 0 { bar_width.max(1) } else { 0 };
638        let bar = "█".repeat(bar_width);
639        let range = if b.val_min == b.val_max {
640            fmt_sig(b.val_min)
641        } else {
642            format!("{}-{}", fmt_sig(b.val_min), fmt_sig(b.val_max))
643        };
644        writeln!(out, "    {:>17}: {:>6} {}{}", range, b.count, bar, marker)?;
645    }
646    Ok(())
647}
648
649#[cfg(test)]
650mod tests;