Functional Weave
Code in Python

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

  • grade_for_mark(78, 100, boundaries ×9) → grade 9, min mark 78, next grade —, marks to next — a mark exactly on the top boundary earns the top grade
  • grade_for_mark(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 it
  • grade_for_mark(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.

def grade_for_mark(mark: int, max_mark: int, boundaries: Sequence[GradeBoundary]) -> GradeResult
markintthe raw mark, 0 to maxMark
max_markintthe paper's total, which bounds both the mark and the boundaries
boundariesGradeBoundary[]one row per grade in any order; a grade starts at its minMark, inclusive
returnsGradeResultthe grade awarded, and how far the mark is from the next one

The types it declares, generated into your project

@dataclass(frozen=True)
class GradeBoundary:
    """The lowest mark that earns a grade."""

    grade: str
    min_mark: int

@dataclass(frozen=True)
class GradeResult:
    """The grade a mark earns and the gap to the next."""

    #: null when the mark is below every boundary: ungraded
    grade: Optional[str]
    #: the boundary of the grade awarded; null when ungraded
    min_mark: Optional[int]
    #: the grade above the one awarded; null at the top
    next_grade: Optional[str]
    #: marks still needed to reach nextGrade; null at the top
    marks_to_next: Optional[int]

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

from fune.education.grade_boundaries import grade_for_mark  # education.grade-boundaries@^1
impl/python.py · 53 lines · open · raw
from typing import Sequence

from .education_grade_boundaries_types import GradeBoundary, GradeResult


def _is_int(value: object) -> bool:
    return isinstance(value, int) and not isinstance(value, bool)


def grade_for_mark(mark: int, max_mark: int, boundaries: Sequence[GradeBoundary]) -> GradeResult:
    """The grade a mark earns from a boundary table, and the gap to the next grade.

    Rows are sorted by min_mark here because published tables run highest
    grade first, and a lookup that assumes ascending order hands out the wrong
    grade.
    """
    if not _is_int(max_mark) or max_mark < 1:
        raise ValueError("maxMark must be a positive whole number, received %s" % (max_mark,))
    if not _is_int(mark) or mark < 0 or mark > max_mark:
        raise ValueError("mark must be a whole number from 0 to maxMark (%s), received %s" % (max_mark, mark))
    if len(boundaries) == 0:
        raise ValueError("boundaries must not be empty")
    seen = set()
    for b in boundaries:
        if not isinstance(b.grade, str) or len(b.grade) == 0:
            raise ValueError("every boundary needs a non-empty grade")
        if not _is_int(b.min_mark) or b.min_mark < 0 or b.min_mark > max_mark:
            raise ValueError(
                "boundary minMark must be a whole number from 0 to maxMark (%s), received %s for grade %s"
                % (max_mark, b.min_mark, b.grade)
            )
        if b.grade in seen:
            raise ValueError("grade %s appears twice in boundaries" % (b.grade,))
        seen.add(b.grade)
    ordered = sorted(boundaries, key=lambda b: b.min_mark)
    for i in range(1, len(ordered)):
        if ordered[i].min_mark == ordered[i - 1].min_mark:
            raise ValueError(
                "grades %s and %s share the boundary %s" % (ordered[i - 1].grade, ordered[i].grade, ordered[i].min_mark)
            )

    index = -1
    for i, b in enumerate(ordered):
        if b.min_mark <= mark:
            index = i
    awarded = ordered[index] if index >= 0 else None
    nxt = ordered[index + 1] if index + 1 < len(ordered) else None
    return GradeResult(
        grade=None if awarded is None else awarded.grade,
        min_mark=None if awarded is None else awarded.min_mark,
        next_grade=None if nxt is None else nxt.grade,
        marks_to_next=None if nxt is None else nxt.min_mark - mark,
    )

Install

fune build

With that line in your source, in a Python project (language python in fune.project), fune build resolves it and nothing else, 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.grade-boundaries
Download for Python education.grade-boundaries-1.0.0-python.fune · 18,455 bytes sha256 d962c234c5ff5f41896e54160a6295c82789c4c631afc011514e14d3f4bee05a

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

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.

CaseArgumentsExpected
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
CaseArgumentsExpected
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

PathBytes
README.md1,548
impl/python.py2,369
impl/rust.rs3,903
impl/typescript.ts2,221
vectors.json9,912