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.
pub fn weighted_grade(components: &[GradeComponent], decimals: i64, mode: &str) -> 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
/// One assessed component: a mark out of a total, and its share of the final grade.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GradeComponent {
/// a label for the caller; not used in the sum
pub name: String,
/// 0 to outOf
pub mark: i64,
/// the component's total, 1 or more
pub out_of: i64,
/// share of the final grade: 4000 = 40%
pub weight_basis_points: i64,
}
/// The weighted percentage, exact and rounded.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WeightedGrade {
/// the exact weighted percentage, reduced
pub percent: Rational,
/// percent rounded to decimals places, times 10^decimals: 74.3% at 1 place is 743
pub scaled: i64,
pub decimals: i64,
}
Your code names it in one line, in the file that uses it
fune!(education.weighted-grade@^1); // then call weighted_grade(…)
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::math_rational::{add_rational, multiply_rational, rational, rational_to_integer, rational_to_value}; ← from math.rational ^1.0.0 · built alongside by fune
/// 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.
///
/// # Panics
/// Panics on an empty list, a mark outside 0 to out_of, weights that do not
/// total 10000, decimals outside 0 to 6, or an unknown rounding mode.
pub fn weighted_grade(components: &[GradeComponent], decimals: i64, mode: &str) -> WeightedGrade {
if components.is_empty() {
panic!("components must not be empty");
}
if !(0..=6).contains(&decimals) {
panic!("decimals must be a whole number from 0 to 6, received {}", decimals);
}
let mut total = rational(0, 1);
let mut weights: i64 = 0;
for c in components {
if c.out_of < 1 {
panic!("outOf must be a whole number of 1 or more, received {} for {}", c.out_of, c.name);
}
if c.mark < 0 || c.mark > c.out_of {
panic!(
"mark must be a whole number from 0 to outOf ({}), received {} for {}",
c.out_of, c.mark, c.name
);
}
if c.weight_basis_points < 0 {
panic!(
"weightBasisPoints must be a whole number of 0 or more, received {} for {}",
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 {
panic!("weights must add up to 10000 basis points (100%), received {}", weights);
}
let scaled = rational_to_integer(&multiply_rational(&total, &rational(10i64.pow(decimals as u32), 1)), mode);
WeightedGrade { percent: total, scaled, decimals }
}
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 weighted_grade_to_value(g: &WeightedGrade) -> Value {
Value::obj(vec![
("percent", rational_to_value(&g.percent)),
("scaled", Value::Int(g.scaled)),
("decimals", Value::Int(g.decimals)),
])
}
pub fn fune_vector(args: &[Value]) -> Value {
let components: Vec<GradeComponent> = args[0]
.as_arr()
.iter()
.map(|v| {
let out_of = whole(v.get("outOf"), "outOf must be a whole number of 1 or more");
GradeComponent {
name: v.get("name").as_str().to_string(),
mark: whole(v.get("mark"), &format!("mark must be a whole number from 0 to outOf ({})", out_of)),
out_of,
weight_basis_points: whole(v.get("weightBasisPoints"), "weightBasisPoints must be a whole number of 0 or more"),
}
})
.collect();
let decimals = whole(&args[1], "decimals must be a whole number from 0 to 6");
weighted_grade_to_value(&weighted_grade(&components, decimals, args[2].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.weighted-grade
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./education.weighted-grade-1.0.0-rust.fune, or fetch it from a terminal with fune pull education.weighted-grade@1.0.0:rust.
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 |