Functional Weave
Code in TypeScript

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.

export function gpa(courses: readonly GpaCourse[], gradePoints: readonly GradePoint[] | null, mode: RoundingMode): GpaResult
coursesGpaCourse[]one entry per course taken, in any order
gradePointsGradePoint[]?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. */
export interface GpaCourse {
  /** the letter grade exactly as the table spells it */
  readonly grade: string;
  /** credit hours, 0 or more; scale every course by 10 for half credits */
  readonly credits: number;
}

/** What a letter grade is worth. */
export interface GradePoint {
  readonly grade: string;
  /** hundredths of a point: 3.7 is 370; null for a grade that does not count towards the GPA (P, W) */
  readonly points: number | null;
}

/** The GPA and the totals it comes from. */
export interface GpaResult {
  /** hundredths of a point, rounded once: 3.12 is 312; null when no course counts */
  readonly gpa: number | null;
  /** points x credits over the counted courses, in hundredths */
  readonly qualityPoints: number;
  /** credits of the courses that count */
  readonly gpaCredits: number;
  /** credits of the courses whose grade does not count (pass, withdrawn) */
  readonly excludedCredits: number;
}

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

import { gpa } from "#fune/education.gpa@^1";
impl/typescript.ts · 53 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.

import { type RoundingMode, roundDiv } from "./math_round_div.ts";  ← from math.round-div ^1.0.0 · built alongside by fune
import { DEFAULT_GRADE_POINTS } from "./education_gpa_data.ts";  ← this capability’s own data, compiled from data/default-grade-points.json into the same file by fune build
import { type GpaCourse, type GpaResult, type GradePoint } from "./education_gpa_types.ts";

/**
 * 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.
 */
export function gpa(courses: readonly GpaCourse[], gradePoints: readonly GradePoint[] | null, mode: RoundingMode): GpaResult {
  const table: readonly GradePoint[] = gradePoints === null ? DEFAULT_GRADE_POINTS : gradePoints;
  if (table.length === 0) {
    throw new RangeError("gradePoints must not be empty; pass null for the default table");
  }
  const points = new Map<string, number | null>();
  for (const row of table) {
    if (points.has(row.grade)) {
      throw new RangeError(`grade "${row.grade}" appears twice in gradePoints`);
    }
    if (row.points !== null && (!Number.isInteger(row.points) || row.points < 0)) {
      throw new RangeError(`points must be whole hundredths of 0 or more, received ${row.points} for grade "${row.grade}"`);
    }
    points.set(row.grade, row.points);
  }

  let qualityPoints = 0;
  let gpaCredits = 0;
  let excludedCredits = 0;
  for (const course of courses) {
    if (!Number.isInteger(course.credits) || course.credits < 0) {
      throw new RangeError(`credits must be a whole number of 0 or more, received ${course.credits}`);
    }
    if (!points.has(course.grade)) {
      throw new RangeError(`grade "${course.grade}" is not in the grade-point table`);
    }
    const p = points.get(course.grade) as number | null;
    if (p === null) {
      excludedCredits += course.credits;
    } else {
      qualityPoints += p * course.credits;
      gpaCredits += course.credits;
    }
  }
  // Validate the mode even when there is nothing to divide.
  roundDiv(0, 1, mode);
  return {
    gpa: gpaCredits === 0 ? null : roundDiv(qualityPoints, gpaCredits, mode),
    qualityPoints,
    gpaCredits,
    excludedCredits,
  };
}

Install

fune build

With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 1 dependency, pins them in fune.lock, downloads only the TypeScript 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 TypeScript education.gpa-1.0.0-typescript.fune · 16,336 bytes sha256 1b55722175c86d0d2682263bb4ab938676d2b24d8b17e39ce0b1a376f3956317

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

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