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.
pub fn degree_classification(modules: &[ModuleMark], level_weights: &[LevelWeight], decimals: i64, mode: &str) -> 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
/// One module's result.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ModuleMark {
/// FHEQ level: 4, 5 and 6 for years one to three of a bachelor's degree
pub level: i64,
/// 0 to 100
pub mark: i64,
/// 1 or more
pub credits: i64,
}
/// How much one level counts towards the degree.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LevelWeight {
pub level: i64,
/// relative to the other levels: 1 and 2, 40 and 60, or 4000 and 6000 all work
pub weight: i64,
}
/// The average the class is decided on, and the class.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DegreeClassification {
/// the exact weighted average mark, reduced
pub average: Rational,
/// the average rounded to decimals places, times 10^decimals
pub scaled: i64,
pub decimals: i64,
pub classification: String,
/// First, Upper second (2:1), Lower second (2:2), Third or Unclassified
pub label: String,
/// credits of the modules that counted
pub counted_credits: i64,
}
// DegreeClass is a string in Rust, one of: "first", "upper-second", "lower-second", "third", "unclassified".
// Parameters take it as &str and results hold it as String.
Your code names it in one line, in the file that uses it
fune!(education.degree-classification@^1); // then call degree_classification(…)
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
use super::funejson::Value; ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::education_degree_classification_data::DEGREE_CLASSES; ← this capability’s own data, compiled from data/degree-classes.json into the same file by fune build
use super::math_rational::{
add_rational, divide_rational, multiply_rational, rational, rational_to_integer, rational_to_value,
};
/// Honours classification: credit-weighted mean per level, levels combined by
/// their weights, rounded once, then banded.
///
/// # Panics
/// Panics on a mark outside 0 to 100, credits below 1, an empty, duplicated or
/// all-zero weighting, a weighted level with no modules, decimals outside 0 to
/// 6, or an unknown rounding mode.
pub fn degree_classification(
modules: &[ModuleMark],
level_weights: &[LevelWeight],
decimals: i64,
mode: &str,
) -> DegreeClassification {
if level_weights.is_empty() {
panic!("levelWeights must name at least one level");
}
if !(0..=6).contains(&decimals) {
panic!("decimals must be a whole number from 0 to 6, received {}", decimals);
}
for m in modules {
if m.mark < 0 || m.mark > 100 {
panic!("mark must be a whole number from 0 to 100, received {}", m.mark);
}
if m.credits < 1 {
panic!("credits must be a whole number of 1 or more, received {}", m.credits);
}
}
let mut total_weight: i64 = 0;
let mut sum = rational(0, 1);
let mut counted_credits: i64 = 0;
for (i, lw) in level_weights.iter().enumerate() {
if lw.weight < 0 {
panic!("weight must be a whole number of 0 or more, received {} for level {}", lw.weight, lw.level);
}
if level_weights[..i].iter().any(|o| o.level == lw.level) {
panic!("level {} appears twice in levelWeights", lw.level);
}
if lw.weight == 0 {
continue;
}
let mut marks: i64 = 0;
let mut credits: i64 = 0;
for m in modules.iter().filter(|m| m.level == lw.level) {
marks += m.mark * m.credits;
credits += m.credits;
}
if credits == 0 {
panic!("no modules at level {}, which levelWeights counts", lw.level);
}
counted_credits += credits;
total_weight += lw.weight;
sum = add_rational(&sum, &multiply_rational(&rational(marks, credits), &rational(lw.weight, 1)));
}
if total_weight == 0 {
panic!("levelWeights must not all be zero");
}
let average = divide_rational(&sum, &rational(total_weight, 1));
let scale = 10i64.pow(decimals as u32);
let scaled = rational_to_integer(&multiply_rational(&average, &rational(scale, 1)), mode);
let mut bands: Vec<_> = DEGREE_CLASSES.iter().collect();
bands.sort_by(|a, b| b.min_mark.cmp(&a.min_mark));
let band = bands
.iter()
.find(|b| scaled >= b.min_mark * scale)
.copied()
.unwrap_or(bands[bands.len() - 1]);
DegreeClassification {
average,
scaled,
decimals,
classification: band.classification.to_string(),
label: band.label.to_string(),
counted_credits,
}
}
fn whole(value: &Value, message: &str) -> i64 {
match value {
Value::Float(f) if f.fract() != 0.0 => panic!("{}, received {}", message, f),
_ => value.as_i64(),
}
}
pub fn degree_classification_to_value(d: &DegreeClassification) -> Value {
Value::obj(vec![
("average", rational_to_value(&d.average)),
("scaled", Value::Int(d.scaled)),
("decimals", Value::Int(d.decimals)),
("classification", Value::str(&d.classification)),
("label", Value::str(&d.label)),
("countedCredits", Value::Int(d.counted_credits)),
])
}
pub fn fune_vector(args: &[Value]) -> Value {
let modules: Vec<ModuleMark> = args[0]
.as_arr()
.iter()
.map(|v| ModuleMark {
level: v.get("level").as_i64(),
mark: whole(v.get("mark"), "mark must be a whole number from 0 to 100"),
credits: whole(v.get("credits"), "credits must be a whole number of 1 or more"),
})
.collect();
let weights: Vec<LevelWeight> = args[1]
.as_arr()
.iter()
.map(|v| LevelWeight {
level: v.get("level").as_i64(),
weight: whole(v.get("weight"), "weight must be a whole number of 0 or more"),
})
.collect();
let decimals = whole(&args[2], "decimals must be a whole number from 0 to 6");
degree_classification_to_value(°ree_classification(&modules, &weights, decimals, args[3].as_str()))
}Install
fune build
With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 2 dependencies, pins them in fune.lock, downloads only the Rust 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. A crate’s build.rs runs it before every compile. 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 Rust implementation. Install it without the registry with fune add ./education.degree-classification-1.0.0-rust.fune, or fetch it from a terminal with fune pull education.degree-classification@1.0.0:rust.
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 |