Functional Weave
Code in Python

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 exam
  • weighted_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
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

@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
impl/python.py · 41 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.

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
Download for Python education.weighted-grade-1.0.0-python.fune · 16,419 bytes sha256 fadf0aedbbbab4062a83d056aa6f09d7988b5f796a9ed809376a1cc2089f60c9

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.

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