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 0round_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.
def round_float(value: float, decimals: int) -> float
| value | float | any finite number |
| decimals | int | 0 to 12 |
| returns | float | the 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
from fune.math.round_float import round_float # math.round-float@^1
import math
POW10 = [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.
_INTEGRAL = 4503599627370496.0
# Veltkamp's splitting constant, 2^27 + 1.
_SPLIT = 134217729.0
def _product_error(a: float, b: float, p: float) -> float:
"""a * b - fl(a * b), exactly (Dekker's TwoProduct), with only * and -."""
ca = _SPLIT * a
ah = ca - (ca - a)
al = a - ah
cb = _SPLIT * b
bh = cb - (cb - b)
bl = b - bh
return ((ah * bh - p) + ah * bl + al * bh) + al * bl
def round_float(value: float, decimals: int) -> float:
"""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. Python's round() is
half-even (round(0.125, 2) == 0.12); this gives 0.13. The product's
rounding error is recovered exactly, so the tie test is on the true value,
with the same operations in the same order as TypeScript and Rust.
"""
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
raise TypeError("value must be a finite number, received %r" % (value,))
if isinstance(decimals, bool) or not isinstance(decimals, int) or decimals < 0 or decimals > 12:
raise ValueError("decimals must be a whole number from 0 to 12, received %r" % (decimals,))
scale = POW10[decimals]
a = abs(float(value))
y = a * scale
if y >= _INTEGRAL:
return float(value) + 0.0
r = float(math.floor(y))
# 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.
above = (y - r - 0.5) + _product_error(a, scale, y)
if above >= 0:
r += 1.0
out = r / scale
# + 0.0 turns -0.0 into 0.0.
return (-out if value < 0 else out) + 0.0Install
fune build
With that line in your source, in a Python project (language python in fune.project), fune build resolves it and nothing else, pins them in fune.lock, downloads only the Python 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. Or pin a range in fune.project and build in one step:
fune add math.round-float
The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./math.round-float-1.0.0-python.fune, or fetch it from a terminal with fune pull math.round-float@1.0.0:python.
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.
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Path | Bytes |
|---|---|
| README.md | 2,695 |
| impl/python.py | 1,957 |
| impl/rust.rs | 2,286 |
| impl/typescript.ts | 2,249 |
| vectors.json | 2,264 |