Functional Weave
Code in TypeScript

education.weighted-grade

Weighted percentage across coursework and exam components, exact as a fraction, then rounded once.

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

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

What it does

The overall percentage for a module or course made of weighted components: coursework worth 40% marked out of 60, an exam worth 60% marked out of 120, and so on. Each component contributes `mark / outOf x weight`, the sum is kept as an exact fraction (`percent`), and it is rounded once, at the end, to the places and rounding mode the caller names (`scaled`).

## Why exact, then one rounding

For example

  • weightedGrade(components ×2, 1, half-up) → percent …, scaled 740, decimals 1 coursework 45/60 at 40% and exam 88/120 at 60% is exactly 74%
  • weightedGrade(components ×3, 2, half-up) → percent …, scaled 6,033, decimals 2 equal-ish thirds with the extra basis point on the exam
  • weightedGrade(components ×2, 0, half-up) → percent …, scaled 70, decimals 0 an exact half rounds up under half-up

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 weightedGrade(components: readonly GradeComponent[], decimals: number, mode: RoundingMode): WeightedGrade
componentsGradeComponent[]every assessed component; the weights must add up to 10000 basis points
decimalsint0 to 6 places in the rounded percentage
modeRoundingModehow the one rounding step breaks ties
returnsWeightedGrade

The types it declares, generated into your project

/** One assessed component: a mark out of a total, and its share of the final grade. */
export interface GradeComponent {
  /** a label for the caller; not used in the sum */
  readonly name: string;
  /** 0 to outOf */
  readonly mark: number;
  /** the component's total, 1 or more */
  readonly outOf: number;
  /** share of the final grade: 4000 = 40% */
  readonly weightBasisPoints: number;
}

/** The weighted percentage, exact and rounded. */
export interface WeightedGrade {
  /** the exact weighted percentage, reduced */
  readonly percent: Rational;
  /** percent rounded to decimals places, times 10^decimals: 74.3% at 1 place is 743 */
  readonly scaled: number;
  readonly decimals: number;
}

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

import { weightedGrade } from "#fune/education.weighted-grade@^1";
impl/typescript.ts · 38 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 Rational, addRational, multiplyRational, rational, rationalToInteger } from "./math_rational.ts";  ← from math.rational ^1.0.0 · built alongside by fune
import { type RoundingMode } from "./math_round_div.ts";  ← from math.round-div ^1.0.0 · built alongside by fune
import { type GradeComponent, type WeightedGrade } from "./education_weighted_grade_types.ts";

/**
 * Weighted percentage across components, summed exactly and rounded once.
 *
 * Each component adds mark x weight / (outOf x 100) percent: mark/outOf of the
 * component, times weight/10000 of the whole, times 100 for a percentage.
 */
export function weightedGrade(components: readonly GradeComponent[], decimals: number, mode: RoundingMode): WeightedGrade {
  if (components.length === 0) {
    throw new RangeError("components must not be empty");
  }
  if (!Number.isInteger(decimals) || decimals < 0 || decimals > 6) {
    throw new RangeError(`decimals must be a whole number from 0 to 6, received ${decimals}`);
  }
  let total: Rational = rational(0, 1);
  let weights = 0;
  for (const c of components) {
    if (!Number.isInteger(c.outOf) || c.outOf < 1) {
      throw new RangeError(`outOf must be a whole number of 1 or more, received ${c.outOf} for ${c.name}`);
    }
    if (!Number.isInteger(c.mark) || c.mark < 0 || c.mark > c.outOf) {
      throw new RangeError(`mark must be a whole number from 0 to outOf (${c.outOf}), received ${c.mark} for ${c.name}`);
    }
    if (!Number.isInteger(c.weightBasisPoints) || c.weightBasisPoints < 0) {
      throw new RangeError(`weightBasisPoints must be a whole number of 0 or more, received ${c.weightBasisPoints} for ${c.name}`);
    }
    weights += c.weightBasisPoints;
    total = addRational(total, rational(c.mark * c.weightBasisPoints, c.outOf * 100));
  }
  if (weights !== 10000) {
    throw new RangeError(`weights must add up to 10000 basis points (100%), received ${weights}`);
  }
  const scaled = rationalToInteger(multiplyRational(total, rational(10 ** decimals, 1)), mode);
  return { percent: total, scaled, decimals };
}

Install

fune build

With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 2 dependencies, 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 education.weighted-grade
Download for TypeScript education.weighted-grade-1.0.0-typescript.fune · 16,285 bytes sha256 4a2b57596a8cdc89af207238a7af91a732027f4ae0d7e0405353e56e7c7e9229

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

The whole function, every language, is one file too: education.weighted-grade-1.0.0.fune, 21,780 bytes, sha256 00502fea3ca0bb6ac3d075fe288927fcfb06d8b723174710e715fd67fa3a8596. 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 education.weighted-grade

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

// fune: after education.weighted-grade

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.rational in education.weighted-grade
// fune: replace math.round-div in education.weighted-grade

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 education.weighted-grade --steps.

// fune: step education.weighted-grade 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
coursework 45/60 at 40% and exam 88/120 at 60% is exactly 74% components ×2, 1, half-up → percent …, scaled 740, decimals 1
equal-ish thirds with the extra basis point on the exam components ×3, 2, half-up → percent …, scaled 6,033, decimals 2
an exact half rounds up under half-up components ×2, 0, half-up → percent …, scaled 70, decimals 0
the same half truncates under down components ×2, 0, down → percent …, scaled 69, decimals 0
68.5 goes to the even 68 under half-even components ×2, 0, half-even → percent …, scaled 68, decimals 0
68.5 goes to 69 under half-up components ×2, 0, half-up → percent …, scaled 69, decimals 0
10/30 and 29/300 at 50% each is exactly 21.5, which floats make 21.4999... and round to 21 components ×2, 0, half-up → percent …, scaled 22, decimals 0
a repeating two-thirds at two places, half-up components ×1, 2, half-up → percent …, scaled 6,667, decimals 2
the same two-thirds cut down components ×1, 2, down → percent …, scaled 6,666, decimals 2
the same two-thirds rounded up to whole marks components ×1, 0, up → percent …, scaled 67, decimals 0
Show the other 12 tests
CaseArgumentsExpected
no marks at all components ×1, 0, half-up → percent …, scaled 0, decimals 0
full marks components ×1, 1, half-up → percent …, scaled 1,000, decimals 1
a zero-weight formative piece adds nothing components ×2, 1, half-up → percent …, scaled 800, decimals 1
weights totalling 99.99% are an error components ×2, 1, half-up → error: weights must add up to 10000 basis points (100%), received 9999
a mark above outOf is an error components ×2, 1, half-up → error: mark must be a whole number from 0 to outOf (60), received 61
a half mark is an error components ×2, 1, half-up → error: mark must be a whole number from 0 to outOf (60), received 44.5
a negative mark is an error components ×2, 1, half-up → error: mark must be a whole number from 0 to outOf (60), received -1
outOf of zero is an error components ×1, 1, half-up → error: outOf must be a whole number of 1 or more, received 0
a negative weight is an error components ×2, 1, half-up → error: weightBasisPoints must be a whole number of 0 or more, received -1
no components is an error , 1, half-up → error: components must not be empty
seven decimal places is an error components ×2, 7, half-up → error: decimals must be a whole number from 0 to 6, received 7
an unknown rounding mode is an error components ×2, 1, nearest → error: unknown rounding mode "nearest"

More from the author

Component percentages are usually repeating decimals (45 out of 60 is fine; 88 out of 120 is 73.333...). Adding them as floats can land a hair under a .5 and round the wrong way: 10/30 at 50% plus 29/300 at 50% is exactly 21.5, which floats compute as 21.4999999..., so a half-up round to whole marks gives 21 instead of 22. A vector pins that case. Rounding each component before adding is a second, commoner source of drift and is not done here.

## Decisions

- **Weights are basis points and must total 10000.** A table that adds up to 99.99% is almost always a typo, so it is refused rather than normalised. Equal thirds are 3333, 3333, 3334: say which component carries the extra basis point. - A weight of 0 is allowed (a formative piece recorded alongside), and adds nothing. - `scaled` is an integer so no float ever carries the answer: 74.3% at one decimal place is `743`. Grade the student with `education.grade-boundaries` on the scaled value if your boundaries are in percent. - Components not yet sat, capped resits and compensation rules are the institution's regulations and are out of scope: pass only marks that count.

## Errors

An empty list, a mark outside 0 to `outOf`, `outOf` below 1, a negative weight, weights not totalling 10000, `decimals` outside 0 to 6, or an unknown rounding mode all raise.

Files

PathBytes
README.md1,761
impl/python.py2,096
impl/rust.rs3,204
impl/typescript.ts1,967
vectors.json8,247