stats.mean-median
Mean, median and mode of a list of integers, with the mean and median as exact fractions.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 16 tests, run in TypeScript, Python and Rust.
What it does
Mean, median and mode of a list of integers, in one pass over one sorted copy.
The mean and median are returned as exact fractions in lowest terms (`7/2`, not `3.5` and certainly not `3`, which is what integer division gives). Each also comes as a float, `meanValue` and `medianValue`, computed by one IEEE-754 division of the exact numerator by the exact denominator. That single division is correctly rounded in every language, so TypeScript, Python and Rust return the same double to the last bit; there is no accumulated float error to disagree about, because the sum is an exact integer.
For example
mean_median_mode(1, 2, 3, 4)→ count 4, sum 10, mean …, mean value 2.5, median …, median value 2.5, modes 1, 2, 3, 4, mode frequency 1 an even count has a fractional mean and medianmean_median_mode(3, 4)→ count 2, sum 7, mean …, mean value 3.5, median …, median value 3.5, modes 3, 4, mode frequency 1 the mean of 3 and 4 is 7/2, not the 3 integer division givesmean_median_mode(2, 2, 3, 9, 1)→ count 5, sum 17, mean …, mean value 3.4, median …, median value 2, modes 2, mode frequency 2 unsorted odd-length input takes the middle of the sorted values
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 mean_median_mode(values: Sequence[int]) -> CentralTendency
| values | int[] | whole numbers in any order, at least one; scale decimals first (pence, grams) |
| returns | CentralTendency |
The types it declares, generated into your project
@dataclass(frozen=True)
class Fraction:
"""An exact rational number in lowest terms; the denominator is always positive."""
numerator: int
denominator: int
@dataclass(frozen=True)
class CentralTendency:
"""The three averages of one list, exact where they can be."""
count: int
sum: int
mean: Fraction
#: sum / count, the nearest binary64 to the exact mean
mean_value: float
#: denominator 1 or 2
median: Fraction
median_value: float
#: every value with the highest frequency, ascending
modes: List[int]
#: 1 means no value repeats, so every value is a mode
mode_frequency: int
Your code names it in one line, in the file that uses it
from fune.stats.mean_median import mean_median_mode # stats.mean-median@^1
from math import gcd
from typing import List, Sequence
from .stats_mean_median_types import CentralTendency, Fraction
MAX_SAFE = 9007199254740991
def _fraction(numerator: int, denominator: int) -> Fraction:
g = gcd(numerator, denominator) or 1
return Fraction(numerator=numerator // g, denominator=denominator // g)
def mean_median_mode(values: Sequence[int]) -> CentralTendency:
"""Mean, median and mode of a list of integers.
Mean and median are exact fractions; their float forms come from a single
correctly rounded division, so every language returns the same double.
"""
if isinstance(values, (str, bytes)) or not isinstance(values, (list, tuple)):
raise TypeError("values must be a list of integers")
if len(values) == 0:
raise ValueError("values must not be empty")
for v in values:
# bool is an int in Python; every other language rejects it, so do too.
if isinstance(v, bool) or not isinstance(v, int):
raise TypeError("values must be integers, received %r" % (v,))
if abs(v) > MAX_SAFE:
raise ValueError("values must be safe integers (magnitude at most 9007199254740991), received %d" % v)
total = sum(values)
if abs(total) > MAX_SAFE:
raise ValueError("sum exceeds the safe integer range")
count = len(values)
ordered = sorted(values)
if count % 2 == 1:
median = Fraction(numerator=ordered[(count - 1) // 2], denominator=1)
else:
pair = ordered[count // 2 - 1] + ordered[count // 2]
if pair % 2 == 0:
median = Fraction(numerator=pair // 2, denominator=1)
else:
if abs(pair) > MAX_SAFE:
raise ValueError("median exceeds the safe integer range")
median = Fraction(numerator=pair, denominator=2)
# Runs of equal values in the sorted copy give frequencies in ascending
# value order, so the modes come out sorted without a second sort.
modes: List[int] = []
mode_frequency = 0
i = 0
while i < count:
j = i
while j < count and ordered[j] == ordered[i]:
j += 1
run = j - i
if run > mode_frequency:
mode_frequency = run
modes = [ordered[i]]
elif run == mode_frequency:
modes.append(ordered[i])
i = j
# float(a) / float(b) with both exact (|a| <= 2^53) is one correctly
# rounded IEEE division, the same double TypeScript and Rust produce.
return CentralTendency(
count=count,
sum=total,
mean=_fraction(total, count),
mean_value=float(total) / float(count) + 0.0,
median=median,
median_value=float(median.numerator) / float(median.denominator) + 0.0,
modes=modes,
mode_frequency=mode_frequency,
)Install
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 stats.mean-median
The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./stats.mean-median-1.0.0-python.fune, or fetch it from a terminal with fune pull stats.mean-median@1.0.0:python.
The whole function, every language, is one file too: stats.mean-median-1.0.0.fune, 19,364 bytes, sha256 f90bdb4452a1f9b675c30352a8731b29cf937dc78ea902e7b0d2a54d83d9bdf5. 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 stats.mean-median
after — your function gets the result and the arguments, and returns the final result.
# fune: after stats.mean-median
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 stats.mean-median --steps.
# fune: step stats.mean-median 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 | |
|---|---|---|---|
| an even count has a fractional mean and median | 1, 2, 3, 4 | → | count 4, sum 10, mean …, mean value 2.5, median …, median value 2.5, modes 1, 2, 3, 4, mode frequency 1 |
| the mean of 3 and 4 is 7/2, not the 3 integer division gives | 3, 4 | → | count 2, sum 7, mean …, mean value 3.5, median …, median value 3.5, modes 3, 4, mode frequency 1 |
| unsorted odd-length input takes the middle of the sorted values | 2, 2, 3, 9, 1 | → | count 5, sum 17, mean …, mean value 3.4, median …, median value 2, modes 2, mode frequency 2 |
| the median sorts numerically, not as strings (10 is not before 9) | 10, 9, 1 | → | count 3, sum 20, mean …, mean value 6.667, median …, median value 9, modes 1, 9, 10, mode frequency 1 |
| tied modes are all returned, ascending | 5, 3, 5, 3, 1 | → | count 5, sum 17, mean …, mean value 3.4, median …, median value 3, modes 3, 5, mode frequency 2 |
| negative values keep the sign on the numerator | -3, -2 | → | count 2, sum -5, mean …, mean value -2.5, median …, median value -2.5, modes -3, -2, mode frequency 1 |
| a single value is its own mean, median and mode | 7 | → | count 1, sum 7, mean …, mean value 7, median …, median value 7, modes 7, mode frequency 1 |
| a zero mean is 0/1 in lowest terms | -4, 4 | → | count 2, sum 0, mean …, mean value 0, median …, median value 0, modes -4, 4, mode frequency 1 |
| fractions are reduced to lowest terms | 2, 4, 6, 8 | → | count 4, sum 20, mean …, mean value 5, median …, median value 5, modes 2, 4, 6, 8, mode frequency 1 |
| a repeating mean is exact as a fraction | 1, 1, 2 | → | count 3, sum 4, mean …, mean value 1.333, median …, median value 1, modes 1, mode frequency 2 |
Show the other 6 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| values at the edge of the safe range | 9,007,199,254,740,991, -9,007,199,254,740,991 | → | count 2, sum 0, mean …, mean value 0, median …, median value 0, modes -9,007,199,254,740,991, 9,007,199,254,740,991, mode frequency 1 |
| an empty list is an error | → | error: values must not be empty | |
| a fractional value is an error | 1, 2.5 | → | error: values must be integers |
| a value past 2^53 - 1 is an error | 9,007,199,254,740,992 | → | error: values must be safe integers |
| a sum past the safe range is an error, not a rounded answer | 9,007,199,254,740,991, 1 | → | error: sum exceeds the safe integer range |
| an odd median pair past the safe range is an error | -9,007,199,254,740,991, 4,503,599,627,370,496, 4,503,599,627,370,497, 4,503,599,627,370,497 | → | error: median exceeds the safe integer range |
More from the author
Inputs are integers on purpose. Averages of money, weights or counts should be taken in their smallest unit (pence, grams), where they are exact; divide at the edge. Every value must be a safe integer (magnitude at most 2^53 - 1, the range all three languages share), and so must the sum. A list whose sum leaves that range is an error, not a silently rounded answer. The same goes for the median of an even-length list when the two middle values add up past it and do not halve evenly.
The median sorts numerically. A naive JavaScript `values.sort()` sorts as strings and puts 10 before 9; the vectors pin that down. The input list is never mutated.
`modes` lists every value that shares the highest frequency, ascending. When no value repeats, `modeFrequency` is 1 and every value is listed: whether that counts as "no mode" is the caller's call, and `modeFrequency` makes it a one-line test.
An empty list is an error: there is no honest mean of nothing.
Files
| Path | Bytes |
|---|---|
| README.md | 1,577 |
| impl/python.py | 2,829 |
| impl/rust.rs | 4,284 |
| impl/typescript.ts | 3,117 |
| vectors.json | 4,002 |