Functional Weave
Code in Rust

education.gpa

Grade point average on a 4.0 scale from letter grades and credit hours, weighted by credits and 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

A grade point average on the 4.0 scale: each course's grade points times its credit hours, summed (the quality points), divided by the credits that count, and rounded once to hundredths. The answer is an integer in hundredths (`312` is a 3.12 GPA), so no float carries it.

## The grade-point table

For example

  • gpa(courses ×3, —, half-up) → gpa 312, quality points 3,120, gpa credits 10, excluded credits 0 credit-weighted: A(3), B+(4), C(3) is 3.12, not the unweighted 3.10
  • gpa(courses ×2, —, half-up) → gpa 348, quality points 1,390, gpa credits 4, excluded credits 0 a half-hundredth rounds up under half-up: A(1), B+(3) is 3.475
  • gpa(courses ×2, —, down) → gpa 347, quality points 1,390, gpa credits 4, excluded credits 0 the same 3.475 truncated to 3.47 under down

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 gpa(courses: &[GpaCourse], grade_points: Option<&[GradePoint]>, mode: &str) -> GpaResult
coursesGpaCourse[]one entry per course taken, in any order
grade_pointsGradePoint[]?the institution's table; null uses the documented default 4.0 table
modeRoundingModehow the one rounding step to hundredths breaks ties; many registrars use down
returnsGpaResult

The types it declares, generated into your project

/// One course on a transcript.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GpaCourse {
    /// the letter grade exactly as the table spells it
    pub grade: String,
    /// credit hours, 0 or more; scale every course by 10 for half credits
    pub credits: i64,
}

/// What a letter grade is worth.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GradePoint {
    pub grade: String,
    /// hundredths of a point: 3.7 is 370; null for a grade that does not count towards the GPA (P, W)
    pub points: Option<i64>,
}

/// The GPA and the totals it comes from.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct GpaResult {
    /// hundredths of a point, rounded once: 3.12 is 312; null when no course counts
    pub gpa: Option<i64>,
    /// points x credits over the counted courses, in hundredths
    pub quality_points: i64,
    /// credits of the courses that count
    pub gpa_credits: i64,
    /// credits of the courses whose grade does not count (pass, withdrawn)
    pub excluded_credits: i64,
}

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

fune!(education.gpa@^1);  // then call gpa(…)
impl/rust.rs · 102 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::education_gpa_data::DEFAULT_GRADE_POINTS;  ← this capability’s own data, compiled from data/default-grade-points.json into the same file by fune build
use super::math_round_div::round_div;  ← from math.round-div ^1.0.0 · built alongside by fune

/// Credit-weighted grade point average in hundredths, rounded once.
///
/// Quality points are summed exactly in integers (hundredths x credits), so
/// the rounding mode the caller names is the only rounding that happens.
///
/// # Panics
/// Panics on an empty or duplicated table, a grade not in the table, negative
/// credits or points, or an unknown rounding mode.
pub fn gpa(courses: &[GpaCourse], grade_points: Option<&[GradePoint]>, mode: &str) -> GpaResult {
    let table: Vec<(String, Option<i64>)> = match grade_points {
        Some(rows) => rows.iter().map(|r| (r.grade.clone(), r.points)).collect(),
        None => DEFAULT_GRADE_POINTS.iter().map(|r| (r.grade.to_string(), r.points)).collect(),
    };
    if table.is_empty() {
        panic!("gradePoints must not be empty; pass null for the default table");
    }
    for (i, (grade, points)) in table.iter().enumerate() {
        if table[..i].iter().any(|(g, _)| g == grade) {
            panic!("grade \"{}\" appears twice in gradePoints", grade);
        }
        if let Some(p) = points {
            if *p < 0 {
                panic!("points must be whole hundredths of 0 or more, received {} for grade \"{}\"", p, grade);
            }
        }
    }

    let mut quality_points: i64 = 0;
    let mut gpa_credits: i64 = 0;
    let mut excluded_credits: i64 = 0;
    for course in courses {
        if course.credits < 0 {
            panic!("credits must be a whole number of 0 or more, received {}", course.credits);
        }
        let row = table.iter().find(|(g, _)| *g == course.grade);
        match row {
            None => panic!("grade \"{}\" is not in the grade-point table", course.grade),
            Some((_, None)) => excluded_credits += course.credits,
            Some((_, Some(p))) => {
                quality_points += p * course.credits;
                gpa_credits += course.credits;
            }
        }
    }
    // Validate the mode even when there is nothing to divide.
    round_div(0, 1, mode);
    GpaResult {
        gpa: if gpa_credits == 0 { None } else { Some(round_div(quality_points, gpa_credits, mode)) },
        quality_points,
        gpa_credits,
        excluded_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 gpa_result_to_value(r: &GpaResult) -> Value {
    Value::obj(vec![
        ("gpa", match r.gpa { Some(n) => Value::Int(n), None => Value::Null }),
        ("qualityPoints", Value::Int(r.quality_points)),
        ("gpaCredits", Value::Int(r.gpa_credits)),
        ("excludedCredits", Value::Int(r.excluded_credits)),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    let courses: Vec<GpaCourse> = args[0]
        .as_arr()
        .iter()
        .map(|v| GpaCourse {
            grade: v.get("grade").as_str().to_string(),
            credits: whole(v.get("credits"), "credits must be a whole number of 0 or more"),
        })
        .collect();
    let table: Option<Vec<GradePoint>> = if args[1].is_null() {
        None
    } else {
        Some(
            args[1]
                .as_arr()
                .iter()
                .map(|v| {
                    let p = v.get("points");
                    GradePoint {
                        grade: v.get("grade").as_str().to_string(),
                        points: if p.is_null() { None } else { Some(whole(p, "points must be whole hundredths of 0 or more")) },
                    }
                })
                .collect(),
        )
    };
    gpa_result_to_value(&gpa(&courses, table.as_deref(), 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 1 dependency, 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.gpa
Download for Rust education.gpa-1.0.0-rust.fune · 18,091 bytes sha256 f1646625884771487bdc05f22d372d4807c94129e621df85befd8b3c80eaecf3

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

The whole function, every language, is one file too: education.gpa-1.0.0.fune, 22,646 bytes, sha256 6bb2027db4e91afabfd82eb1878bbc8519678d8dd28e88c166eeb480f053a5de. 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.gpa

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

// fune: after education.gpa

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.round-div in education.gpa

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.gpa --steps.

// fune: step education.gpa 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
credit-weighted: A(3), B+(4), C(3) is 3.12, not the unweighted 3.10 courses ×3, —, half-up → gpa 312, quality points 3,120, gpa credits 10, excluded credits 0
a half-hundredth rounds up under half-up: A(1), B+(3) is 3.475 courses ×2, —, half-up → gpa 348, quality points 1,390, gpa credits 4, excluded credits 0
the same 3.475 truncated to 3.47 under down courses ×2, —, down → gpa 347, quality points 1,390, gpa credits 4, excluded credits 0
3.525 goes to the even 3.52 under half-even courses ×2, —, half-even → gpa 352, quality points 1,410, gpa credits 4, excluded credits 0
a repeating 3.333... is 3.33 half-up courses ×3, —, half-up → gpa 333, quality points 3,000, gpa credits 9, excluded credits 0
and 3.34 rounded up courses ×3, —, up → gpa 334, quality points 3,000, gpa credits 9, excluded credits 0
pass and withdrawn grades are left out of both sums courses ×3, —, half-up → gpa 400, quality points 1,200, gpa credits 3, excluded credits 7
an F counts its credits at zero points courses ×2, —, half-up → gpa 200, quality points 1,200, gpa credits 6, excluded credits 0
only pass grades means no GPA, not 0.00 courses ×1, —, half-up → gpa —, quality points 0, gpa credits 0, excluded credits 3
no courses at all means no GPA , —, half-up → gpa —, quality points 0, gpa credits 0, excluded credits 0
Show the other 12 tests
CaseArgumentsExpected
a zero-credit course adds nothing courses ×2, —, half-up → gpa 300, quality points 900, gpa credits 3, excluded credits 0
an institution's own table with A+ at 4.33 courses ×2, grade points ×4, half-up → gpa 367, quality points 2,199, gpa credits 6, excluded credits 0
the same, truncated courses ×2, grade points ×4, down → gpa 366, quality points 2,199, gpa credits 6, excluded credits 0
half credits scaled by ten: A(35), B(10) courses ×2, —, half-up → gpa 378, quality points 17,000, gpa credits 45, excluded credits 0
a grade not in the table is an error courses ×1, —, half-up → error: grade "D-" is not in the grade-point table
grades match case exactly courses ×1, —, half-up → error: grade "a" is not in the grade-point table
negative credits are an error courses ×1, —, half-up → error: credits must be a whole number of 0 or more, received -1
fractional credits are an error courses ×1, —, half-up → error: credits must be a whole number of 0 or more, received 1.5
an empty table is an error courses ×1, , half-up → error: gradePoints must not be empty
a grade twice in the table is an error courses ×1, grade points ×2, half-up → error: grade "A" appears twice in gradePoints
negative points are an error courses ×1, grade points ×1, half-up → error: points must be whole hundredths of 0 or more, received -10 for grade "A"
an unknown rounding mode is an error courses ×1, —, nearest → error: unknown rounding mode "nearest"

More from the author

Institutions set their own tables, so pass yours as `gradePoints`. With `null` the default is the common US conversion published by the College Board (BigFuture, "How to Convert Your GPA to a 4.0 Scale"): A+ and A 4.0, A- 3.7, B+ 3.3, B 3.0, B- 2.7, C+ 2.3, C 2.0, C- 1.7, D+ 1.3, D 1.0, E and F 0.0. The default also knows P (pass) and W (withdrawn) as grades that do not count. It has no D-: schools that use one disagree on its value (0.7 is common), so a D- is refused unless your table has it. Some schools give A+ 4.33; pass a table with `points` 433 for that.

The table is a convention, not a rule that changes by date, so it carries no `effective` columns and always ships whole.

## Decisions

- **Credit-weighted.** A 4-credit B+ counts four times a 1-credit A. The unweighted mean of the grade points is a common mistake; a vector pins it. - **Fails count, passes do not.** An F adds its credits with 0 points; a grade whose `points` is null (P, W, I, audit) is left out of both sums and its credits reported as `excludedCredits`. - **One rounding, the caller's mode.** Many registrars truncate (`down`) rather than round; the exact quality points are returned too. - **No course that counts means no GPA**: `gpa` is null, not 0.00. - Grades match exactly, case included: "a" is not "A". - Repeated-course replacement, weighted honours/AP scales and transfer credit rules are institution policy and out of scope: pass only the courses that count.

## Sources

- College Board, BigFuture, "How to Convert Your GPA to a 4.0 Scale": https://bigfuture.collegeboard.org/plan-for-college/get-started/how-to-calculate-gpa-4.0-scale

Files

PathBytes
README.md1,968
data/default-grade-points.json533
impl/python.py2,253
impl/rust.rs3,821
impl/typescript.ts2,133
vectors.json6,746