Functional Weave
Code in TypeScript

stats.weighted-average

Weighted average of integers, computed exactly and rounded once to stated decimals with an explicit rounding mode.

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

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

What it does

Sum of value x weight, divided by the sum of the weights: the average price paid over several purchases, a grade point average weighted by credits, a stock's average cost.

Everything up to the last step is integer arithmetic, so the only rounding is the one the caller asked for. The exact quotient sum(v x w) x 10^decimals / sum(w) is rounded once by `math.round-div` in the given mode (half-up, half-even, down or up), and the resulting integer is divided by 10^decimals. That last division returns the nearest binary64 to a decimal with at most fifteen significant digits, which is the same double in every language and prints back as exactly that decimal. There is no float accumulation to drift.

For example

  • weightedAverage(90, 80, 3, 1, 1, half-up) → 87.5 a grade weighted 3 to 1
  • weightedAverage(10, 20, 1, 3, 2, half-up) → 17.5 weights change the answer: one item at 10 and three at 20 average 17.5, not 15
  • weightedAverage(199, 249, 3, 2, 0, half-up) → 219 prices in pence weighted by quantity

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.

export function weightedAverage(values: readonly number[], weights: readonly number[], decimals: number, mode: RoundingMode): number
valuesint[]whole numbers in their smallest unit (pence, grams, marks)
weightsint[]one per value, 0 or greater, not all 0 (quantities, credits, shares)
decimalsint0 to 9 places in the result
modeRoundingModehow the single rounding step breaks ties
returnsfloat

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

import { weightedAverage } from "#fune/stats.weighted-average@^1";
impl/typescript.ts · 48 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.

import { type RoundingMode, roundDiv } from "./math_round_div.ts";  ← from math.round-div ^1.0.0 · built alongside by fune

const MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER);

/**
 * Weighted average of integers, rounded once at the end.
 *
 * The numerator and denominator are exact integers, so the rounding mode the
 * caller names is the only rounding that ever happens.
 */
export function weightedAverage(
  values: readonly number[],
  weights: readonly number[],
  decimals: number,
  mode: RoundingMode,
): number {
  if (!Array.isArray(values) || !Array.isArray(weights)) {
    throw new TypeError("values and weights must be lists of integers");
  }
  if (values.length !== weights.length) {
    throw new RangeError(`values and weights must be the same length, received ${values.length} and ${weights.length}`);
  }
  if (values.length === 0) throw new RangeError("values must not be empty");
  if (!Number.isInteger(decimals) || decimals < 0 || decimals > 9) {
    throw new RangeError(`decimals must be a whole number from 0 to 9, received ${decimals}`);
  }

  // BigInt so an overflow is detected, not silently rounded away.
  let numerator = BigInt(0);
  let denominator = BigInt(0);
  for (let i = 0; i < values.length; i++) {
    const v = values[i];
    const w = weights[i];
    if (typeof v !== "number" || !Number.isSafeInteger(v)) throw new TypeError(`values must be integers, received ${v}`);
    if (typeof w !== "number" || !Number.isSafeInteger(w)) throw new TypeError(`weights must be integers, received ${w}`);
    if (w < 0) throw new RangeError(`weights must not be negative, received ${w}`);
    numerator += BigInt(v) * BigInt(w);
    denominator += BigInt(w);
  }
  if (denominator === BigInt(0)) throw new RangeError("weights must not all be zero");

  const scale = BigInt(10) ** BigInt(decimals);
  const scaled = numerator * scale;
  if (scaled > MAX_SAFE || scaled < -MAX_SAFE || denominator > MAX_SAFE) {
    throw new RangeError("weighted sum is too large to average exactly; lower decimals or rescale the values");
  }
  return roundDiv(Number(scaled), Number(denominator), mode) / 10 ** decimals + 0;
}

Install

fune build

With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 1 dependency, pins them in fune.lock, downloads only the TypeScript 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.weighted-average
Download for TypeScript stats.weighted-average-1.0.0-typescript.fune · 8,049 bytes sha256 e46c8840fc6339795016bd51af7f53b1a39357410144f989ffd8f14d9ec5d4e2

The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./stats.weighted-average-1.0.0-typescript.fune, or fetch it from a terminal with fune pull stats.weighted-average@1.0.0:typescript.

The whole function, every language, is one file too: stats.weighted-average-1.0.0.fune, 13,212 bytes, sha256 b7bb1a17d5815e23abb96433395dccabe366ec5e241f044edb1bfa80f4b08b40. 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.weighted-average

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

// fune: after stats.weighted-average

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 math.round-div in stats.weighted-average

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.weighted-average --steps.

// fune: step stats.weighted-average 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 grade weighted 3 to 1 90, 80, 3, 1, 1, half-up → 87.5
weights change the answer: one item at 10 and three at 20 average 17.5, not 15 10, 20, 1, 3, 2, half-up → 17.5
prices in pence weighted by quantity 199, 249, 3, 2, 0, half-up → 219
87.5 to whole numbers rounds half up to 88 90, 80, 3, 1, 0, half-up → 88
2.5 half-up is 3 2, 3, 1, 1, 0, half-up → 3
2.5 half-even is 2 2, 3, 1, 1, 0, half-even → 2
1.5 half-even is 2 1, 2, 1, 1, 0, half-even → 2
a third to four places 1, 0, 0, 1, 1, 1, 4, half-up → 0.333
a third rounded up 1, 0, 0, 1, 1, 1, 4, up → 0.333
down truncates toward zero for negatives -2, -1, -1, 1, 1, 1, 2, down → -1.33
Show the other 11 tests
CaseArgumentsExpected
up rounds away from zero for negatives -2, -1, -1, 1, 1, 1, 2, up → -1.34
a zero weight contributes nothing 100, 5,000, 1, 0, 0, half-up → 100
nine decimals of an exact answer 1, 3, 9, half-up → 1
a zero average is 0 -5, 5, 2, 2, 3, half-up → 0
mismatched lengths are an error 1, 2, 1, 0, half-up → error: values and weights must be the same length
an empty list is an error , , 0, half-up → error: values must not be empty
a negative weight is an error 1, 2, 1, -1, 0, half-up → error: weights must not be negative
all-zero weights are an error 1, 2, 0, 0, 0, half-up → error: weights must not all be zero
decimals above 9 is an error 1, 1, 10, half-up → error: decimals must be a whole number from 0 to 9
a fractional value is an error 1.5, 1, 0, half-up → error: values must be integers
a weighted sum past 2^53 - 1 once scaled is an error 9,007,199,254,740,991, 1, 1, half-up → error: weighted sum is too large to average exactly

More from the author

Values and weights are integers on purpose. Take money in pence and weights in whole units (or scale fractional weights, 0.25 as 25); then the average is exact before it is rounded.

Weights of zero are allowed and simply contribute nothing. Negative weights are an error, and so is a list where every weight is zero: there is no average of nothing.

The weighted sum, scaled by 10^decimals, must stay within 2^53 - 1 (the range integers share across TypeScript, Python and Rust). Beyond that the capability refuses rather than rounding silently; lower `decimals` or rescale the values.

A naive "average of the averages" (15 for one item at 10 and three at 20) is the classic mistake here; the answer is 17.5.

Files

PathBytes
README.md1,440
impl/python.py2,128
impl/rust.rs2,844
impl/typescript.ts2,090
vectors.json2,475