education.grade-boundaries
Grade for a mark from the caller's boundary table: inclusive lower bounds, ungraded below the lowest.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 20 tests, run in TypeScript, Python and Rust.
What it does
Turns a raw mark into a grade using a boundary table the caller supplies: each grade starts at its `minMark`, inclusive, and a mark below the lowest boundary is ungraded (`grade` is null). It also says which grade is next and how many marks short the candidate is, which is what a results page or a "marks to the next grade" report shows.
## Why the table is an argument
For example
gradeForMark(78, 100, boundaries ×9)→ grade 9, min mark 78, next grade —, marks to next — a mark exactly on the top boundary earns the top gradegradeForMark(77, 100, boundaries ×9)→ grade 8, min mark 68, next grade 9, marks to next 1 one mark below a boundary is the grade beneath itgradeForMark(100, 100, boundaries ×9)→ grade 9, min mark 78, next grade —, marks to next — full marks
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 gradeForMark(mark: number, maxMark: number, boundaries: readonly GradeBoundary[]): GradeResult
| mark | int | the raw mark, 0 to maxMark |
| maxMark | int | the paper's total, which bounds both the mark and the boundaries |
| boundaries | GradeBoundary[] | one row per grade in any order; a grade starts at its minMark, inclusive |
| returns | GradeResult | the grade awarded, and how far the mark is from the next one |
The types it declares, generated into your project
/** The lowest mark that earns a grade. */
export interface GradeBoundary {
readonly grade: string;
readonly minMark: number;
}
/** The grade a mark earns and the gap to the next. */
export interface GradeResult {
/** null when the mark is below every boundary: ungraded */
readonly grade: string | null;
/** the boundary of the grade awarded; null when ungraded */
readonly minMark: number | null;
/** the grade above the one awarded; null at the top */
readonly nextGrade: string | null;
/** marks still needed to reach nextGrade; null at the top */
readonly marksToNext: number | null;
}
Your code names it in one line, in the file that uses it
import { gradeForMark } from "#fune/education.grade-boundaries@^1";
import { type GradeBoundary, type GradeResult } from "./education_grade_boundaries_types.ts";
/**
* The grade a mark earns from a boundary table, and the gap to the next grade.
*
* Rows are sorted by minMark here because published tables run highest grade
* first, and a lookup that assumes ascending order hands out the wrong grade.
*/
export function gradeForMark(mark: number, maxMark: number, boundaries: readonly GradeBoundary[]): GradeResult {
if (!Number.isInteger(maxMark) || maxMark < 1) {
throw new RangeError(`maxMark must be a positive whole number, received ${maxMark}`);
}
if (!Number.isInteger(mark) || mark < 0 || mark > maxMark) {
throw new RangeError(`mark must be a whole number from 0 to maxMark (${maxMark}), received ${mark}`);
}
if (boundaries.length === 0) {
throw new RangeError("boundaries must not be empty");
}
const seen = new Set<string>();
for (const b of boundaries) {
if (typeof b.grade !== "string" || b.grade.length === 0) {
throw new RangeError("every boundary needs a non-empty grade");
}
if (!Number.isInteger(b.minMark) || b.minMark < 0 || b.minMark > maxMark) {
throw new RangeError(`boundary minMark must be a whole number from 0 to maxMark (${maxMark}), received ${b.minMark} for grade ${b.grade}`);
}
if (seen.has(b.grade)) {
throw new RangeError(`grade ${b.grade} appears twice in boundaries`);
}
seen.add(b.grade);
}
const sorted = [...boundaries].sort((a, b) => a.minMark - b.minMark);
for (let i = 1; i < sorted.length; i++) {
if (sorted[i].minMark === sorted[i - 1].minMark) {
throw new RangeError(`grades ${sorted[i - 1].grade} and ${sorted[i].grade} share the boundary ${sorted[i].minMark}`);
}
}
let index = -1;
for (let i = 0; i < sorted.length; i++) {
if (sorted[i].minMark <= mark) index = i;
}
const awarded = index >= 0 ? sorted[index] : null;
const next = index + 1 < sorted.length ? sorted[index + 1] : null;
return {
grade: awarded === null ? null : awarded.grade,
minMark: awarded === null ? null : awarded.minMark,
nextGrade: next === null ? null : next.grade,
marksToNext: next === null ? null : next.minMark - mark,
};
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and nothing else, 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.grade-boundaries
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./education.grade-boundaries-1.0.0-typescript.fune, or fetch it from a terminal with fune pull education.grade-boundaries@1.0.0:typescript.
The whole function, every language, is one file too: education.grade-boundaries-1.0.0.fune, 24,816 bytes, sha256 0244e03bc42f9de3b6c884928d7d37962273ba424a8d0d4914d82db11fbdc5eb. 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.grade-boundaries
after — your function gets the result and the arguments, and returns the final result.
// fune: after education.grade-boundaries
replace — it requires no other capability, so there is no dependency to replace.
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.grade-boundaries --steps.
// fune: step education.grade-boundaries 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 | |
|---|---|---|---|
| a mark exactly on the top boundary earns the top grade | 78, 100, boundaries ×9 | → | grade 9, min mark 78, next grade —, marks to next — |
| one mark below a boundary is the grade beneath it | 77, 100, boundaries ×9 | → | grade 8, min mark 68, next grade 9, marks to next 1 |
| full marks | 100, 100, boundaries ×9 | → | grade 9, min mark 78, next grade —, marks to next — |
| a mid-table mark, and the marks to the next grade | 30, 100, boundaries ×9 | → | grade 4, min mark 30, next grade 5, marks to next 9 |
| one mark below the lowest boundary is ungraded | 4, 100, boundaries ×9 | → | grade —, min mark —, next grade 1, marks to next 1 |
| zero is ungraded and the whole lowest boundary away | 0, 100, boundaries ×9 | → | grade —, min mark —, next grade 1, marks to next 5 |
| a table in no particular order: 59 is a C, one short of a B, not the last row read | 59, 100, boundaries ×6 | → | grade C, min mark 50, next grade B, marks to next 1 |
| A* at its boundary in a shuffled table | 80, 100, boundaries ×6 | → | grade A*, min mark 80, next grade —, marks to next — |
| a lowest row at 0 means no mark is ungraded | 0, 100, boundaries ×4 | → | grade Fail, min mark 0, next grade Pass, marks to next 40 |
| a single-row table: pass at the boundary | 50, 50, boundaries ×1 | → | grade Pass, min mark 50, next grade —, marks to next — |
Show the other 10 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a single-row table: below it is ungraded | 49, 50, boundaries ×1 | → | grade —, min mark —, next grade Pass, marks to next 1 |
| a mark above maxMark is an error | 101, 100, boundaries ×9 | → | error: mark must be a whole number from 0 to maxMark (100) |
| a negative mark is an error | -1, 100, boundaries ×9 | → | error: mark must be a whole number from 0 to maxMark (100) |
| a half mark is an error | 12.5, 100, boundaries ×9 | → | error: mark must be a whole number from 0 to maxMark (100) |
| maxMark of zero is an error | 0, 0, boundaries ×9 | → | error: maxMark must be a positive whole number |
| an empty table is an error | 10, 100, | → | error: boundaries must not be empty |
| a grade named twice is an error | 10, 100, boundaries ×2 | → | error: grade A appears twice in boundaries |
| two grades on one boundary is an error | 10, 100, boundaries ×2 | → | error: grades A and B share the boundary 70 |
| a boundary above maxMark is an error | 10, 60, boundaries ×9 | → | error: boundary minMark must be a whole number from 0 to maxMark (60), received 78 for grade 9 |
| an empty grade name is an error | 10, 100, boundaries ×1 | → | error: every boundary needs a non-empty grade |
More from the author
Grade boundaries are not rules in force: they are set per paper, per series, after marking (AQA, Pearson, OCR and WJEC publish a fresh table for every exam series), so there is nothing a registry could carry as dated data that would be right for your paper. Pass the published table for the paper and series you are grading.
## Decisions
- **Inclusive lower bounds.** A mark exactly on a boundary earns that grade: boundaries are published as "the minimum mark for grade X". - **Any order.** Tables are usually printed highest grade first; the rows are sorted by `minMark` here, so the order you pass does not matter. - **Ungraded is null, not "U".** Different specifications call it U, X, Fail or "not classified"; label it yourself. A table whose lowest row has `minMark` 0 has no ungraded marks at all. - Integer marks only. A paper marked in halves should be doubled (mark and maxMark and boundaries) before calling.
## Errors
A mark outside 0 to `maxMark`, a non-integer mark, an empty table, a boundary outside 0 to `maxMark`, a grade named twice, or two grades sharing one boundary (the table would be ambiguous) all raise.
Files
| Path | Bytes |
|---|---|
| README.md | 1,548 |
| impl/python.py | 2,369 |
| impl/rust.rs | 3,903 |
| impl/typescript.ts | 2,221 |
| vectors.json | 9,912 |