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.10gpa(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.475gpa(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
| courses | GpaCourse[] | one entry per course taken, in any order |
| grade_points | GradePoint[]? | the institution's table; null uses the documented default 4.0 table |
| mode | RoundingMode | how the one rounding step to hundredths breaks ties; many registrars use down |
| returns | GpaResult |
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(…)
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
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.
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Path | Bytes |
|---|---|
| README.md | 1,968 |
| data/default-grade-points.json | 533 |
| impl/python.py | 2,253 |
| impl/rust.rs | 3,821 |
| impl/typescript.ts | 2,133 |
| vectors.json | 6,746 |