Functional Weave
Code in Python

education.degree-classification

UK honours degree class (First, 2:1, 2:2, Third) from module marks and credits, weighted by level.

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

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

What it does

The classification of a UK bachelor's degree with honours from module marks: each counted level's credit-weighted mean mark, combined by the level weights the caller supplies, rounded as the caller says, and banded:

| class | average | |---|---| | First | 70 and above | | Upper second (2:1) | 60 to 69 | | Lower second (2:2) | 50 to 59 | | Third | 40 to 49 | | Unclassified | below 40 |

For example

  • degree_classification(modules ×11, level weights ×2, 0, half-up) → average …, scaled 68, decimals 0, classification upper-second, label Upper second (2:1), counted credits 240 level 5 at 1 and level 6 at 2: 63 and 70.33 make 67.89, a 2:1 (credits alone would say 66.67)
  • degree_classification(modules ×12, level weights ×2, 0, half-up) → average …, scaled 68, decimals 0, classification upper-second, label Upper second (2:1), counted credits 240 first-year modules are left out when level 4 is not weighted
  • degree_classification(modules ×11, level weights ×2, 0, half-up) → average …, scaled 67, decimals 0, classification upper-second, label Upper second (2:1), counted credits 240 50:50 in basis points

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 degree_classification(modules: Sequence[ModuleMark], level_weights: Sequence[LevelWeight], decimals: int, mode: RoundingMode) -> DegreeClassification
modulesModuleMark[]every module result; modules at a level levelWeights does not name are left out
level_weightsLevelWeight[]the institution's weighting, e.g. level 5 at 1 and level 6 at 2
decimalsint0 to 6: the places the average is rounded to before it is classified
modeRoundingModehow that rounding breaks ties; down classifies on the unrounded average
returnsDegreeClassification

The types it declares, generated into your project

@dataclass(frozen=True)
class ModuleMark:
    """One module's result."""

    #: FHEQ level: 4, 5 and 6 for years one to three of a bachelor's degree
    level: int
    #: 0 to 100
    mark: int
    #: 1 or more
    credits: int

@dataclass(frozen=True)
class LevelWeight:
    """How much one level counts towards the degree."""

    level: int
    #: relative to the other levels: 1 and 2, 40 and 60, or 4000 and 6000 all work
    weight: int

@dataclass(frozen=True)
class DegreeClassification:
    """The average the class is decided on, and the class."""

    #: the exact weighted average mark, reduced
    average: Rational
    #: the average rounded to decimals places, times 10^decimals
    scaled: int
    decimals: int
    classification: DegreeClass
    #: First, Upper second (2:1), Lower second (2:2), Third or Unclassified
    label: str
    #: credits of the modules that counted
    counted_credits: int

DegreeClass = Literal["first", "upper-second", "lower-second", "third", "unclassified"]

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

from fune.education.degree_classification import degree_classification  # education.degree-classification@^1
impl/python.py · 63 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_degree_classification_data import DEGREE_CLASSES  ← this capability’s own data, compiled from data/degree-classes.json into the same file by fune build
from .education_degree_classification_types import DegreeClassification, LevelWeight, ModuleMark
from .math_rational import add_rational, divide_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 degree_classification(
    modules: Sequence[ModuleMark], level_weights: Sequence[LevelWeight], decimals: int, mode: RoundingMode
) -> DegreeClassification:
    """Honours classification: credit-weighted mean per level, levels combined
    by their weights, rounded once, then banded."""
    if len(level_weights) == 0:
        raise ValueError("levelWeights must name at least one level")
    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,))
    for m in modules:
        if not _is_int(m.mark) or m.mark < 0 or m.mark > 100:
            raise ValueError("mark must be a whole number from 0 to 100, received %s" % (m.mark,))
        if not _is_int(m.credits) or m.credits < 1:
            raise ValueError("credits must be a whole number of 1 or more, received %s" % (m.credits,))
    total_weight = 0
    total = rational(0, 1)
    counted_credits = 0
    for i, lw in enumerate(level_weights):
        if not _is_int(lw.weight) or lw.weight < 0:
            raise ValueError("weight must be a whole number of 0 or more, received %s for level %s" % (lw.weight, lw.level))
        for j in range(i):
            if level_weights[j].level == lw.level:
                raise ValueError("level %s appears twice in levelWeights" % (lw.level,))
        if lw.weight == 0:
            continue
        marks = 0
        credits = 0
        for m in modules:
            if m.level == lw.level:
                marks += m.mark * m.credits
                credits += m.credits
        if credits == 0:
            raise ValueError("no modules at level %s, which levelWeights counts" % (lw.level,))
        counted_credits += credits
        total_weight += lw.weight
        total = add_rational(total, multiply_rational(rational(marks, credits), rational(lw.weight, 1)))
    if total_weight == 0:
        raise ValueError("levelWeights must not all be zero")
    average = divide_rational(total, rational(total_weight, 1))
    scale = 10**decimals
    scaled = rational_to_integer(multiply_rational(average, rational(scale, 1)), mode)
    bands = sorted(DEGREE_CLASSES, key=lambda b: -b.min_mark)
    band = next((b for b in bands if scaled >= b.min_mark * scale), bands[-1])
    return DegreeClassification(
        average=average,
        scaled=scaled,
        decimals=decimals,
        classification=band.classification,
        label=band.label,
        counted_credits=counted_credits,
    )

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.degree-classification
Download for Python education.degree-classification-1.0.0-python.fune · 26,163 bytes sha256 96872ce610b2c10f97131a03e5d1ea9b12d826b9e668e6f2da97df59665fe379

The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./education.degree-classification-1.0.0-python.fune, or fetch it from a terminal with fune pull education.degree-classification@1.0.0:python.

The whole function, every language, is one file too: education.degree-classification-1.0.0.fune, 33,932 bytes, sha256 febcae3badc9347319cb25fe69bebe049faa2e89ed485f38df082dc927f35d89. 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.degree-classification

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

# fune: after education.degree-classification

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.degree-classification
# fune: replace math.round-div in education.degree-classification

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.degree-classification --steps.

# fune: step education.degree-classification 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
level 5 at 1 and level 6 at 2: 63 and 70.33 make 67.89, a 2:1 (credits alone would say 66.67) modules ×11, level weights ×2, 0, half-up → average …, scaled 68, decimals 0, classification upper-second, label Upper second (2:1), counted credits 240
first-year modules are left out when level 4 is not weighted modules ×12, level weights ×2, 0, half-up → average …, scaled 68, decimals 0, classification upper-second, label Upper second (2:1), counted credits 240
50:50 in basis points modules ×11, level weights ×2, 0, half-up → average …, scaled 67, decimals 0, classification upper-second, label Upper second (2:1), counted credits 240
final year only: a large module counts double its credits, 70.33 is a First modules ×11, level weights ×2, 0, half-up → average …, scaled 70, decimals 0, classification first, label First, counted credits 120
69.5 rounded to a whole mark is a First modules ×2, level weights ×1, 0, half-up → average …, scaled 70, decimals 0, classification first, label First, counted credits 120
the same 69.5 rounded to one place stays a 2:1 modules ×2, level weights ×1, 1, half-up → average …, scaled 695, decimals 1, classification upper-second, label Upper second (2:1), counted credits 120
the same 69.5 truncated stays a 2:1 modules ×2, level weights ×1, 0, down → average …, scaled 69, decimals 0, classification upper-second, label Upper second (2:1), counted credits 120
exactly 60 is a 2:1 modules ×3, level weights ×1, 0, half-up → average …, scaled 60, decimals 0, classification upper-second, label Upper second (2:1), counted credits 120
exactly 50 is a 2:2 modules ×1, level weights ×1, 0, half-up → average …, scaled 50, decimals 0, classification lower-second, label Lower second (2:2), counted credits 120
exactly 40 is a Third modules ×1, level weights ×1, 0, half-up → average …, scaled 40, decimals 0, classification third, label Third, counted credits 120
Show the other 13 tests
CaseArgumentsExpected
39 is below a Third modules ×1, level weights ×1, 0, half-up → average …, scaled 39, decimals 0, classification unclassified, label Unclassified, counted credits 120
full marks modules ×1, level weights ×1, 0, half-up → average …, scaled 100, decimals 0, classification first, label First, counted credits 120
zero modules ×1, level weights ×1, 0, half-up → average …, scaled 0, decimals 0, classification unclassified, label Unclassified, counted credits 120
a mark above 100 is an error modules ×1, level weights ×1, 0, half-up → error: mark must be a whole number from 0 to 100, received 101
a half mark is an error modules ×1, level weights ×1, 0, half-up → error: mark must be a whole number from 0 to 100, received 69.5
zero credits is an error modules ×1, level weights ×1, 0, half-up → error: credits must be a whole number of 1 or more, received 0
no level weights is an error modules ×5, , 0, half-up → error: levelWeights must name at least one level
a level weighted twice is an error modules ×5, level weights ×2, 0, half-up → error: level 6 appears twice in levelWeights
a negative weight is an error modules ×5, level weights ×1, 0, half-up → error: weight must be a whole number of 0 or more, received -1 for level 6
all-zero weights are an error modules ×5, level weights ×1, 0, half-up → error: levelWeights must not all be zero
a weighted level with no modules is an error modules ×6, level weights ×2, 0, half-up → error: no modules at level 6, which levelWeights counts
seven decimal places is an error modules ×5, level weights ×1, 7, half-up → error: decimals must be a whole number from 0 to 6, received 7
an unknown rounding mode is an error modules ×5, level weights ×1, 0, nearest → error: unknown rounding mode "nearest"

More from the author

## Institutions differ, so the algorithm is arguments

The four bands are the sector's convention, but every university writes its own degree algorithm: which levels count (level 4, the first year, usually does not), the weight of each (1:2, 40:60, 25:75 and 0:100 are all in use), and whether the average is rounded to a whole mark (69.5 becomes a First) or to one place (69.5 stays a 2:1) before banding. So the level weights, the places and the rounding mode are all the caller's. Use `mode` `down` to classify on the unrounded average: truncation never lifts a mark across a boundary.

Not modelled: borderline and preponderance rules (a 68.5 lifted to a First because most credits are at First level), discounting the worst 20 credits, capped resits, compensation, and integrated masters or Scottish four-year honours schemes. Apply those to the inputs, or to the result, as your regulations say. The bands are a convention, not dated rules, so the table ships whole with no `effective` columns.

## Decisions

- **Two stages of weighting.** Marks are averaged by credits within a level, then levels by their weight. Averaging every module by credits alone, or taking the plain mean of module marks, gives a different answer; a vector pins the difference. - `average` is the exact fraction; `scaled` is it rounded once. - A level with weight above 0 but no modules is an error: a transcript missing its final year should not be classified on its second.

## Sources

- Universities UK and GuildHE, "Principles for effective degree algorithm design" (2020), on why algorithms vary and what to publish: https://www.universitiesuk.ac.uk/what-we-do/policy-and-research/publications/principles-effective-degree-algorithm - The 70 / 60 / 50 / 40 bands as the sector convention: see any university's academic regulations, e.g. the summary in "British undergraduate degree classification", https://en.wikipedia.org/wiki/British_undergraduate_degree_classification

Files

PathBytes
README.md2,402
data/degree-classes.json386
impl/python.py2,952
impl/rust.rs4,519
impl/typescript.ts2,966
vectors.json13,701