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.
def gpa(courses: Sequence[GpaCourse], grade_points: Optional[Sequence[GradePoint]], mode: RoundingMode) -> 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
@dataclass(frozen=True)
class GpaCourse:
"""One course on a transcript."""
#: the letter grade exactly as the table spells it
grade: str
#: credit hours, 0 or more; scale every course by 10 for half credits
credits: int
@dataclass(frozen=True)
class GradePoint:
"""What a letter grade is worth."""
grade: str
#: hundredths of a point: 3.7 is 370; null for a grade that does not count towards the GPA (P, W)
points: Optional[int]
@dataclass(frozen=True)
class GpaResult:
"""The GPA and the totals it comes from."""
#: hundredths of a point, rounded once: 3.12 is 312; null when no course counts
gpa: Optional[int]
#: points x credits over the counted courses, in hundredths
quality_points: int
#: credits of the courses that count
gpa_credits: int
#: credits of the courses whose grade does not count (pass, withdrawn)
excluded_credits: int
Your code names it in one line, in the file that uses it
from fune.education.gpa import gpa # education.gpa@^1
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
from typing import Dict, Optional, Sequence
from .education_gpa_data import DEFAULT_GRADE_POINTS ← this capability’s own data, compiled from data/default-grade-points.json into the same file by fune build
from .education_gpa_types import GpaCourse, GpaResult, GradePoint
from .math_round_div import RoundingMode, round_div ← from math.round-div ^1.0.0 · built alongside by fune
def _is_int(value: object) -> bool:
return isinstance(value, int) and not isinstance(value, bool)
def gpa(courses: Sequence[GpaCourse], grade_points: Optional[Sequence[GradePoint]], mode: RoundingMode) -> GpaResult:
"""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.
"""
table = DEFAULT_GRADE_POINTS if grade_points is None else grade_points
if len(table) == 0:
raise ValueError("gradePoints must not be empty; pass null for the default table")
points: Dict[str, Optional[int]] = {}
for row in table:
if row.grade in points:
raise ValueError('grade "%s" appears twice in gradePoints' % (row.grade,))
if row.points is not None and (not _is_int(row.points) or row.points < 0):
raise ValueError(
'points must be whole hundredths of 0 or more, received %s for grade "%s"' % (row.points, row.grade)
)
points[row.grade] = row.points
quality_points = 0
gpa_credits = 0
excluded_credits = 0
for course in courses:
if not _is_int(course.credits) or course.credits < 0:
raise ValueError("credits must be a whole number of 0 or more, received %s" % (course.credits,))
if course.grade not in points:
raise ValueError('grade "%s" is not in the grade-point table' % (course.grade,))
p = points[course.grade]
if p is None:
excluded_credits += course.credits
else:
quality_points += p * course.credits
gpa_credits += course.credits
# Validate the mode even when there is nothing to divide.
round_div(0, 1, mode)
return GpaResult(
gpa=None if gpa_credits == 0 else round_div(quality_points, gpa_credits, mode),
quality_points=quality_points,
gpa_credits=gpa_credits,
excluded_credits=excluded_credits,
)Install
fune build
With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 1 dependency, pins them in fune.lock, downloads only the Python 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. Or pin a range in fune.project and build in one step:
fune add education.gpa
The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./education.gpa-1.0.0-python.fune, or fetch it from a terminal with fune pull education.gpa@1.0.0:python.
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 |