Functional Weave
Code in Rust

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.

pub fn weighted_grade(components: &[GradeComponent], decimals: i64, mode: &str) -> 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

/// 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(…)
impl/rust.rs · 78 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.

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
Download for Rust education.weighted-grade-1.0.0-rust.fune · 17,578 bytes sha256 cca88e3f0fb397a2cfb9d8bff914d36ff53c6b727efd8598bbfed51da75ef63a

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.

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