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
weighted_grade(components ×2, 1, half-up)→ percent …, scaled 740, decimals 1 coursework 45/60 at 40% and exam 88/120 at 60% is exactly 74%weighted_grade(components ×3, 2, half-up)→ percent …, scaled 6,033, decimals 2 equal-ish thirds with the extra basis point on the examweighted_grade(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.
def weighted_grade(components: Sequence[GradeComponent], decimals: int, mode: RoundingMode) -> WeightedGrade
| components | GradeComponent[] | every assessed component; the weights must add up to 10000 basis points |
| decimals | int | 0 to 6 places in the rounded percentage |
| mode | RoundingMode | how the one rounding step breaks ties |
| returns | WeightedGrade |
The types it declares, generated into your project
@dataclass(frozen=True)
class GradeComponent:
"""One assessed component: a mark out of a total, and its share of the final grade."""
#: a label for the caller; not used in the sum
name: str
#: 0 to outOf
mark: int
#: the component's total, 1 or more
out_of: int
#: share of the final grade: 4000 = 40%
weight_basis_points: int
@dataclass(frozen=True)
class WeightedGrade:
"""The weighted percentage, exact and rounded."""
#: the exact weighted percentage, reduced
percent: Rational
#: percent rounded to decimals places, times 10^decimals: 74.3% at 1 place is 743
scaled: int
decimals: int
Your code names it in one line, in the file that uses it
from fune.education.weighted_grade import weighted_grade # education.weighted-grade@^1
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
from typing import Sequence
from .education_weighted_grade_types import GradeComponent, WeightedGrade
from .math_rational import add_rational, multiply_rational, rational, rational_to_integer ← from math.rational ^1.0.0 · built alongside by fune
from .math_round_div import RoundingMode ← from math.round-div ^1.0.0 · built alongside by fune
def _is_int(value: object) -> bool:
return isinstance(value, int) and not isinstance(value, bool)
def weighted_grade(components: Sequence[GradeComponent], decimals: int, mode: RoundingMode) -> WeightedGrade:
"""Weighted percentage across components, summed exactly and rounded once.
Each component adds mark x weight / (out_of x 100) percent: mark/out_of of
the component, times weight/10000 of the whole, times 100 for a percentage.
"""
if len(components) == 0:
raise ValueError("components must not be empty")
if not _is_int(decimals) or decimals < 0 or decimals > 6:
raise ValueError("decimals must be a whole number from 0 to 6, received %s" % (decimals,))
total = rational(0, 1)
weights = 0
for c in components:
if not _is_int(c.out_of) or c.out_of < 1:
raise ValueError("outOf must be a whole number of 1 or more, received %s for %s" % (c.out_of, c.name))
if not _is_int(c.mark) or c.mark < 0 or c.mark > c.out_of:
raise ValueError(
"mark must be a whole number from 0 to outOf (%s), received %s for %s" % (c.out_of, c.mark, c.name)
)
if not _is_int(c.weight_basis_points) or c.weight_basis_points < 0:
raise ValueError(
"weightBasisPoints must be a whole number of 0 or more, received %s for %s"
% (c.weight_basis_points, c.name)
)
weights += c.weight_basis_points
total = add_rational(total, rational(c.mark * c.weight_basis_points, c.out_of * 100))
if weights != 10000:
raise ValueError("weights must add up to 10000 basis points (100%%), received %s" % (weights,))
scaled = rational_to_integer(multiply_rational(total, rational(10**decimals, 1)), mode)
return WeightedGrade(percent=total, scaled=scaled, decimals=decimals)Install
fune build
With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 2 dependencies, 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 education.weighted-grade
The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./education.weighted-grade-1.0.0-python.fune, or fetch it from a terminal with fune pull education.weighted-grade@1.0.0:python.
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.
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Path | Bytes |
|---|---|
| README.md | 1,761 |
| impl/python.py | 2,096 |
| impl/rust.rs | 3,204 |
| impl/typescript.ts | 1,967 |
| vectors.json | 8,247 |