Functional Weave
Code in Rust

charts.histogram-bins

Bin values into histogram bins on round edges, by Sturges' rule, Freedman-Diaconis, or a fixed width.

1.0.0 · published 2026-10-03 by charlie · Anterra

Pinned by 17 tests, run in TypeScript, Python and Rust.

What it does

Sorts a sample into histogram bins with round edges: `histogramBins([1..10], "sturges", null)` gives five bins, 0-2, 2-4, ..., 8-10. Each bin counts values with `x0 <= v < x1`; the last bin also counts its right edge, so the maximum is never lost off the end.

## How many bins

For example

  • histogram_bins(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, sturges, —) → ×5 Sturges: ten values make five bins of width 2
  • histogram_bins(1, 2, 3, 4, 5, 6, 7, 8, 9, 100, sturges, —) → ×5 Sturges with an outlier: five wide bins
  • histogram_bins(1, 2, 3, 4, 5, 6, 7, 8, 9, 100, freedman-diaconis, —) → ×20 Freedman-Diaconis with the same outlier: narrow bins sized by the quartiles

The function

The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.

pub fn histogram_bins(values: &[f64], method: &str, bin_width: Option<f64>) -> Vec<Bin>
valuesfloat[]the sample, in any order; empty gives no bins
methodBinMethodhow many bins: sturges, freedman-diaconis, or fixed-width
bin_widthfloat?the width for fixed-width, greater than 0; null for the other methods
returnsBin[]adjacent bins from the first edge at or below the minimum to the first at or above the maximum

The types it declares, generated into your project

// BinMethod is a string in Rust, one of: "sturges", "freedman-diaconis", "fixed-width".
// Parameters take it as &str and results hold it as String.

/// One histogram bar: values from x0 up to but not including x1 (the last bin includes x1).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Bin {
    pub x0: f64,
    pub x1: f64,
    pub count: i64,
}

Your code names it in one line, in the file that uses it

fune!(charts.histogram-bins@^1);  // then call histogram_bins(…)
impl/rust.rs · 122 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::charts_ticks_tick_step::tick_spec;
use super::math_pow::pow;  ← from math.pow ^1.0.0 · built alongside by fune
use super::math_round_float::round_float;  ← from math.round-float ^1.0.0 · built alongside by fune
use super::stats_percentile::percentile;  ← from stats.percentile ^1.0.0 · built alongside by fune

const MAX_BINS: i64 = 10000;

/// Histogram bins on round edges, as d3.bin lays them out. Each bin holds
/// x0 <= v < x1; the last also holds its right edge. See the README.
///
/// # Panics
/// Panics on an unknown method, a binWidth given for a method that does not
/// take one or missing for fixed-width, a non-finite value, or more than
/// 10,000 bins.
pub fn histogram_bins(values: &[f64], method: &str, bin_width: Option<f64>) -> Vec<Bin> {
    match method {
        "fixed-width" => match bin_width {
            Some(w) if w.is_finite() && w > 0.0 => {}
            Some(w) => panic!("fixed-width needs a binWidth greater than 0; got {}", w),
            None => panic!("fixed-width needs a binWidth greater than 0; got null"),
        },
        "sturges" | "freedman-diaconis" => {
            if bin_width.is_some() {
                panic!("binWidth is only for fixed-width; pass null for {}", method);
            }
        }
        other => panic!("unknown bin method \"{}\"", other),
    }
    let n = values.len();
    if n == 0 {
        return Vec::new();
    }
    let mut lo = values[0];
    let mut hi = values[0];
    for &v in values {
        if !v.is_finite() {
            panic!("values must be finite numbers; got {}", v);
        }
        if v < lo {
            lo = v;
        }
        if v > hi {
            hi = v;
        }
    }

    let (mul, div) = if method == "fixed-width" {
        (bin_width.unwrap(), 1.0)
    } else {
        if lo == hi {
            return vec![Bin { x0: lo + 0.0, x1: hi + 0.0, count: n as i64 }];
        }
        let count = if method == "sturges" {
            // ceil(log2 n) + 1, by doubling rather than a logarithm.
            let (mut bits, mut power) = (0i64, 1usize);
            while power < n {
                power *= 2;
                bits += 1;
            }
            bits + 1
        } else {
            // Freedman and Diaconis: bin width 2 IQR n^(-1/3); d3 falls back
            // to one bin when the interquartile range is zero.
            let iqr = percentile(values, 75.0, "linear", 12) - percentile(values, 25.0, "linear", 12);
            let width = 2.0 * iqr * pow(n as f64, -1.0 / 3.0);
            let bins = if width > 0.0 { ((hi - lo) / width).ceil() } else { 1.0 };
            if bins > MAX_BINS as f64 {
                panic!("too many bins: more than {}", MAX_BINS);
            }
            (bins as i64).max(1)
        };
        tick_spec(lo, hi, count)
    };

    // One exact operation on a whole number, cleaned at 12 places.
    let edge = |i: i64| round_float(if div > 1.0 { i as f64 / div } else { i as f64 * mul }, 12);
    let mut first = (if div > 1.0 { lo * div } else { lo / mul }).floor() as i64;
    while edge(first) > lo {
        first -= 1;
    }
    while edge(first + 1) <= lo {
        first += 1;
    }
    let mut last = first + 1;
    while edge(last) < hi {
        last += 1;
        if last - first > MAX_BINS {
            panic!("too many bins: more than {}", MAX_BINS);
        }
    }

    let edges: Vec<f64> = (first..=last).map(edge).collect();
    let mut counts = vec![0i64; edges.len() - 1];
    for &v in values {
        let (mut a, mut b) = (0usize, counts.len() - 1);
        while a < b {
            let mid = (a + b + 1) / 2;
            if edges[mid] <= v {
                a = mid;
            } else {
                b = mid - 1;
            }
        }
        counts[a] += 1;
    }
    counts
        .iter()
        .enumerate()
        .map(|(i, &count)| Bin { x0: edges[i], x1: edges[i + 1], count })
        .collect()
}

pub fn bin_to_value(bin: &Bin) -> Value {
    Value::obj(vec![("x0", Value::Float(bin.x0)), ("x1", Value::Float(bin.x1)), ("count", Value::Int(bin.count))])
}

pub fn fune_vector(args: &[Value]) -> Value {
    let values: Vec<f64> = args[0].as_arr().iter().map(|v| v.as_f64()).collect();
    let width = if args[2].is_null() { None } else { Some(args[2].as_f64()) };
    Value::Arr(histogram_bins(&values, args[1].as_str(), width).iter().map(bin_to_value).collect())
}

Install

fune build

With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 4 dependencies, pins them in fune.lock, downloads only the Rust package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. A crate’s build.rs runs it before every compile. Or pin a range in fune.project and build in one step:

fune add charts.histogram-bins
Download for Rust charts.histogram-bins-1.0.0-rust.fune · 13,371 bytes sha256 d438d4fa528bb642a769c50d571035aa942290c7c806a9802e5a0d1cae014797

The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./charts.histogram-bins-1.0.0-rust.fune, or fetch it from a terminal with fune pull charts.histogram-bins@1.0.0:rust.

The whole function, every language, is one file too: charts.histogram-bins-1.0.0.fune, 20,490 bytes, sha256 670e89879fefdf330fd24fb0245816b60333ca6a97345040574f0e8cab8b1915. It installs into a project of any language.

Customise it in your app

The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.

before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.

// fune: before charts.histogram-bins

after — your function gets the result and the arguments, and returns the final result.

// fune: after charts.histogram-bins

replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.

// fune: replace charts.ticks in charts.histogram-bins
// fune: replace math.pow in charts.histogram-bins
// fune: replace math.round-float in charts.histogram-bins
// fune: replace stats.percentile in charts.histogram-bins

step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show charts.histogram-bins --steps.

// fune: step charts.histogram-bins after <n|label>

Tests

A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.

CaseArgumentsExpected
Sturges: ten values make five bins of width 2 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, sturges, — → ×5
Sturges with an outlier: five wide bins 1, 2, 3, 4, 5, 6, 7, 8, 9, 100, sturges, — → ×5
Freedman-Diaconis with the same outlier: narrow bins sized by the quartiles 1, 2, 3, 4, 5, 6, 7, 8, 9, 100, freedman-diaconis, — → ×20
Freedman-Diaconis with no spread in the quartiles falls back to one bin 5, 5, 5, 5, 9, freedman-diaconis, — → ×1
unsorted fractions on fifths 0.12, 0.47, 0.33, 0.91, sturges, — → ×5
fixed width 0.1: 0.3 is on the edge 0.3, not below 0.30000000000000004 0.05, 0.25, 0.3, 0.31, fixed-width, 0.1 → ×4
fixed width: the maximum on an edge goes in the last bin 0, 5, 10, fixed-width, 5 → ×2
fixed width across zero -7, -1, 3, fixed-width, 5 → ×3
fixed width with one value 7, fixed-width, 5 → ×1
one value with Sturges is a bin of no width 4.5, sturges, — → ×1
Show the other 7 tests
CaseArgumentsExpected
equal values with Sturges 3, 3, 3, sturges, — → ×1
no values, no bins , sturges, — →
fixed-width without a width is an error 1, 2, fixed-width, — → error: fixed-width needs a binWidth greater than 0
a zero width is an error 1, 2, fixed-width, 0 → error: fixed-width needs a binWidth greater than 0
a width with Sturges is an error 1, 2, sturges, 2 → error: binWidth is only for fixed-width
an unknown method is an error 1, 2, scott, — → error: unknown bin method "scott"
more than 10,000 bins is an error 0, 100, fixed-width, 0.001 → error: too many bins: more than 10000

More from the author

- **`sturges`**: ceil(log2 n) + 1 bins (Sturges 1926), d3.bin's default. It assumes a roughly normal sample and gives too few bins for large or skewed ones. log2 is found by doubling, not with a logarithm. - **`freedman-diaconis`**: bin width 2 x IQR x n^(-1/3) (Freedman and Diaconis 1981), which follows the middle half of the data and so is not stretched by an outlier. The quartiles are `stats.percentile` with the `linear` method (R-7, as d3's quantile), and n^(-1/3) is `math.pow`. When the interquartile range is zero it falls back to one bin, as d3 does. - **`fixed-width`**: the width you pass, with edges on its multiples (a width of 5 puts edges on ..., -5, 0, 5, 10, ...). `binWidth` must be null for the other two methods: an argument that would be silently ignored is an error.

For the first two, the count is turned into a round width with `charts.ticks` (1, 2 or 5 x 10^k for about that many bins), as d3.bin does with its nice thresholds, so edges fall on numbers a reader expects rather than on min + k x (max - min) / count.

## Edges

The first edge is the multiple of the width at or below the minimum and the last the first at or above the maximum. Each edge is one exact operation on a whole number (i x width, or i / divisor for a fractional round width) and is then cleaned at 12 decimal places with `math.round-float`, so a width of 0.1 has an edge at 0.3, not 0.30000000000000004 as `3 * 0.1` gives, and a value of exactly 0.3 lands in the bin starting there.

No values gives no bins; values that are all equal give one bin of no width under the first two methods. More than 10,000 bins is an error.

Sources: H. A. Sturges, "The Choice of a Class Interval", Journal of the American Statistical Association 21 (1926) 65-66; D. Freedman and P. Diaconis, "On the histogram as a density estimator: L2 theory", Zeitschrift für Wahrscheinlichkeitstheorie 57 (1981) 453-476; Mike Bostock, d3-array `bin.js` and `threshold/*.js`.

Files

PathBytes
README.md2,273
impl/python.py3,254
impl/rust.rs4,250
impl/typescript.ts3,595
vectors.json3,747