Functional Weave
Code in Python

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.

def gpa(courses: Sequence[GpaCourse], grade_points: Optional[Sequence[GradePoint]], mode: RoundingMode) -> 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

@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
impl/python.py · 52 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.

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
Download for Python education.gpa-1.0.0-python.fune · 16,449 bytes sha256 22e227312b7e8a52acf35e276ae2761d85c68ac51fd8ab512c14170b3fdd0eb2

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.

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