Functional Weave
Code in Rust

math.round-float

Round a float half away from zero to 0-12 decimal places, identically in TypeScript, Python and Rust.

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

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

What it does

Rounds a floating-point number to 0-12 decimal places, half away from zero, and returns the same double in TypeScript, Python and Rust. It is the one rounding step for everything in the registry that works in floats (chart geometry, colours, statistics that choose to use it), so "rounded to 2 decimal places" means the same thing everywhere.

## The rule

For example

  • round_float(0.5, 0) → 1 a half rounds up to 1, not to the even 0
  • round_float(2.5, 0) → 3 2.5 rounds away from zero to 3 (Python's round() gives 2)
  • round_float(-2.5, 0) → -3 -2.5 rounds away from zero to -3 (Math.round gives -2)

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 round_float(value: f64, decimals: i64) -> f64
valuefloatany finite number
decimalsint0 to 12
returnsfloatthe nearest multiple of 10^-decimals to the binary value, ties away from zero; never -0

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

fune!(math.round-float@^1);  // then call round_float(…)
impl/rust.rs · 63 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

const POW10: [f64; 13] = [
    1.0, 10.0, 100.0, 1e3, 1e4, 1e5, 1e6, 1e7, 1e8, 1e9, 1e10, 1e11, 1e12,
];

// 2^52: at or above this every double is a whole number, so a scaled value
// this large has no digits left to round.
const INTEGRAL: f64 = 4503599627370496.0;
// Veltkamp's splitting constant, 2^27 + 1.
const SPLIT: f64 = 134217729.0;

/// a * b - fl(a * b), exactly (Dekker's TwoProduct), with only * and -.
/// `mul_add` would do it in one step, but JavaScript has no equivalent, and
/// the point is the same operations everywhere.
fn product_error(a: f64, b: f64, p: f64) -> f64 {
    let ca = SPLIT * a;
    let ah = ca - (ca - a);
    let al = a - ah;
    let cb = SPLIT * b;
    let bh = cb - (cb - b);
    let bl = b - bh;
    ((ah * bh - p) + ah * bl + al * bh) + al * bl
}

/// Round half away from zero to `decimals` places, deciding on the exact value
/// of the double: 2.675 is stored as 2.674999..., so it rounds to 2.67.
///
/// # Panics
/// Panics if `value` is not finite or `decimals` is outside 0..=12.
pub fn round_float(value: f64, decimals: i64) -> f64 {
    if !value.is_finite() {
        panic!("value must be a finite number, received {}", value);
    }
    if !(0..=12).contains(&decimals) {
        panic!("decimals must be a whole number from 0 to 12, received {}", decimals);
    }
    let scale = POW10[decimals as usize];
    let a = value.abs();
    let y = a * scale;
    if y >= INTEGRAL {
        return value + 0.0;
    }
    let mut r = y.floor();
    // y - r is exact, and so is subtracting a half from it; adding the
    // product's error cannot change the sign, only settle a tie.
    let above = (y - r - 0.5) + product_error(a, scale, y);
    if above >= 0.0 {
        r += 1.0;
    }
    let out = r / scale;
    // + 0.0 turns -0.0 into 0.0.
    (if value < 0.0 { -out } else { out }) + 0.0
}

pub fn fune_vector(args: &[Value]) -> Value {
    // Refuse what the typed signature cannot hold, with the wording TypeScript
    // and Python use, rather than let the conversion below quietly change it.
    if matches!(args[1], Value::Float(f) if f.fract() != 0.0) {
        panic!("decimals must be a whole number from 0 to 12");
    }
    Value::Float(round_float(args[0].as_f64(), args[1].as_i64()))
}

Install

fune build

With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and nothing else, 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 math.round-float
Download for Rust math.round-float-1.0.0-rust.fune · 8,992 bytes sha256 7883c0969a50548e0b3a8509ce6fb5cd2a09536226cd1b0f3df9d4ecf5f1f7f7

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

The whole function, every language, is one file too: math.round-float-1.0.0.fune, 13,354 bytes, sha256 636aaefc23d19a399890dd9a83751e6e995ca3862a570b3bb4e52a63f4ad36f2. 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 math.round-float

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

// fune: after math.round-float

replace — it requires no other capability, so there is no dependency to replace.

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 math.round-float --steps.

// fune: step math.round-float 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
a half rounds up to 1, not to the even 0 0.5, 0 → 1
2.5 rounds away from zero to 3 (Python's round() gives 2) 2.5, 0 → 3
-2.5 rounds away from zero to -3 (Math.round gives -2) -2.5, 0 → -3
1.5 rounds to 2 1.5, 0 → 2
0.125 is exact in binary, so it is a true tie and goes up 0.125, 2 → 0.13
-0.125 is a true tie and goes down -0.125, 2 → -0.13
1.005 is stored as 1.00499999..., so it rounds down 1.005, 2 → 1
2.675 is stored as 2.67499999... (2.675 * 100 is exactly 267.5 in floating point, which fools the shortcut) 2.675, 2 → 2.67
8.345 is stored as 8.34500000000000064, so it rounds up 8.345, 2 → 8.35
1.45 is stored just below the tie 1.45, 1 → 1.4
Show the other 12 tests
CaseArgumentsExpected
0.0005 is stored just above the tie 0.001, 3 → 0.001
the largest double below a half rounds to 0 (floor(x + 0.5) gives 1) 0.5, 0 → 0
binary noise from 0.1 + 0.2 is removed at 12 places 0.3, 12 → 0.3
a small negative rounds to zero, not minus zero -0.001, 2 → 0
an integer is unchanged -7, 3 → -7
zero places 1,234.568, 0 → 1,235
one place 123.456, 1 → 123.5
six places 1, 6 → 1
beyond 2^52 in scaled units the double has no digits left to round, and is returned as it is 123,456.789, 12 → 123,456.789
more than 12 places is an error 1.5, 13 → error: decimals must be a whole number from 0 to 12
negative places is an error 1.5, -1 → error: decimals must be a whole number from 0 to 12
fractional places is an error 1.5, 1.5 → error: decimals must be a whole number from 0 to 12

More from the author

The result is the multiple of 10^-decimals nearest to the **exact value of the double you passed**, with an exact tie going away from zero, and then the nearest double to that decimal. So:

- `0.5` → `1`, `2.5` → `3`, `-2.5` → `-3`, `0.125` to 2 places → `0.13`. Those are exact binary values and true ties. Python's `round()` is half-even (2 and 0.12) and JavaScript's `Math.round` rounds -2.5 to -2. - `1.005` to 2 places → `1`, `2.675` → `2.67`, `1.45` to 1 place → `1.4`. None of those literals can be stored exactly; each is stored slightly below the tie (2.675 is 2.67499999999999982236...), so it rounds down. This matches JavaScript's `toFixed`. It does not match the shortcut `Math.round(x * 100) / 100`, which says 2.68, because `2.675 * 100` itself rounds to exactly 267.5 before `Math.round` sees it. - `8.345` → `8.35` and `0.0005` to 3 places → `0.001`: stored slightly above. - `0.49999999999999994` → `0`. `floor(x + 0.5)` says 1, because the addition rounds up to exactly 1. - The result is never `-0`: `-0.001` to 2 places is `0`, so it prints as `0`.

When `|value| x 10^decimals` is 2^52 or more, the double has no digits left at that precision (the gap between neighbouring doubles is already that coarse), and it is returned unchanged. That is at most one unit in the last place from the correctly rounded decimal.

## Why all three languages agree

The scaled value `|value| x 10^decimals` is computed once, its rounding error is recovered exactly with Dekker's TwoProduct (Veltkamp splitting; only `*` and `-`, so no fused multiply-add, which JavaScript lacks), and the tie is decided on their sum. Every step is an IEEE 754 operation the standard defines exactly, in the same order in each language; there is no library call whose last bit could differ.

`stats.*` capabilities published before this one round the scaled product instead (`floor` of `|value| x 10^decimals`, then compare with a half), which agrees everywhere except on literals like 2.675 whose product lands exactly on a tie. New float capabilities should require this one rather than carry their own rounding.

Sources: IEEE 754-2019 (correctly rounded +, -, *, /); T. J. Dekker, "A floating-point technique for extending the available precision", Numerische Mathematik 18 (1971) 224-242.

Files

PathBytes
README.md2,695
impl/python.py1,957
impl/rust.rs2,286
impl/typescript.ts2,249
vectors.json2,264