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 weighteddegree_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
| modules | ModuleMark[] | every module result; modules at a level levelWeights does not name are left out |
| level_weights | LevelWeight[] | the institution's weighting, e.g. level 5 at 1 and level 6 at 2 |
| decimals | int | 0 to 6: the places the average is rounded to before it is classified |
| mode | RoundingMode | how that rounding breaks ties; down classifies on the unrounded average |
| returns | DegreeClassification |
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
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
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.
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Path | Bytes |
|---|---|
| README.md | 2,402 |
| data/degree-classes.json | 386 |
| impl/python.py | 2,952 |
| impl/rust.rs | 4,519 |
| impl/typescript.ts | 2,966 |
| vectors.json | 13,701 |