Functional Weave
Code in Python

education.cpd-hours

CPD logged against a requirement for a rolling or fixed period: minutes counted, shortfall and days left.

1.0.0 · published 2026-10-03 by charlie · Anterra

Pinned by 20 tests, run in TypeScript, Python and Rust.

What it does

Totals a continuing professional development log against a requirement, for the period that applies on a date, and says what is still to do.

Professional bodies measure CPD two ways, and both are supported:

For example

  • cpd_hours(entries ×5, required minutes 2,100, period months 12, rolling false, anchor 2025-04-01, 2026-03-15) → period start 2025-04-01, period end 2026-03-31, logged minutes 840, entries counted 3, required minutes 2,100, shortfall minutes 1,260, met false, days remaining 16 an April-March CPD year on 15 March: the first day counts, the day before and a booked course after today do not
  • cpd_hours(entries ×5, required minutes 800, period months 12, rolling false, anchor 2025-04-01, 2026-03-15) → period start 2025-04-01, period end 2026-03-31, logged minutes 840, entries counted 3, required minutes 800, shortfall minutes 0, met true, days remaining 16 the requirement met
  • cpd_hours(entries ×5, required minutes 840, period months 12, rolling false, anchor 2025-04-01, 2026-03-15) → period start 2025-04-01, period end 2026-03-31, logged minutes 840, entries counted 3, required minutes 840, shortfall minutes 0, met true, days remaining 16 exactly met

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 cpd_hours(entries: Sequence[CpdEntry], requirement: CpdRequirement, on_date: str) -> CpdProgress
entriesCpdEntry[]the learning log, in any order
requirementCpdRequirementhow much is needed over what period
on_datedatethe date to report on; entries after it are not counted yet
returnsCpdProgress

The types it declares, generated into your project

@dataclass(frozen=True)
class CpdEntry:
    """One logged piece of CPD."""

    date: str
    #: 0 or more; 1.5 hours is 90
    minutes: int

@dataclass(frozen=True)
class CpdRequirement:
    """The requirement a professional body sets."""

    #: 35 hours is 2100
    required_minutes: int
    #: length of the period: 12 for a year, 36 for three
    period_months: int
    #: true: the periodMonths ending on onDate; false: fixed periods counted from anchor
    rolling: bool
    #: first day of any one fixed period, e.g. 2025-04-01 for an April to March CPD year; null when rolling
    anchor: Optional[str]

@dataclass(frozen=True)
class CpdProgress:
    """Where the log stands against the requirement."""

    period_start: str
    #: last day of the period, inclusive; onDate for a rolling period
    period_end: str
    #: minutes dated in the period and on or before onDate
    logged_minutes: int
    entries_counted: int
    required_minutes: int
    #: still to do; 0 once met
    shortfall_minutes: int
    met: bool
    #: days from onDate to periodEnd; 0 for a rolling period
    days_remaining: int

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

from fune.education.cpd_hours import cpd_hours  # education.cpd-hours@^1
impl/python.py · 61 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 Sequence

from .dates_add_days import add_days, days_between, parse_iso_date  ← from dates.add-days ^1.0.0 · built alongside by fune
from .dates_add_months import add_months  ← from dates.add-months ^1.0.0 · built alongside by fune
from .education_cpd_hours_types import CpdEntry, CpdProgress, CpdRequirement


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


def cpd_hours(entries: Sequence[CpdEntry], requirement: CpdRequirement, on_date: str) -> CpdProgress:
    """CPD minutes logged in the period that applies on on_date, against the
    requirement. Fixed period boundaries are anchor + k x period_months, each
    worked out from the anchor so month-end clamping never accumulates."""
    on = parse_iso_date(on_date)
    required = requirement.required_minutes
    months = requirement.period_months
    anchor = requirement.anchor
    if not _is_int(required) or required < 0:
        raise ValueError("requiredMinutes must be a whole number of 0 or more, received %s" % (required,))
    if not _is_int(months) or months < 1:
        raise ValueError("periodMonths must be a whole number of 1 or more, received %s" % (months,))
    if requirement.rolling:
        if anchor is not None:
            raise ValueError("a rolling period takes no anchor")
        period_end = on_date
        period_start = add_days(add_months(on_date, -months), 1)
    else:
        if anchor is None:
            raise ValueError("a fixed period needs an anchor date")
        a = parse_iso_date(anchor)
        months_apart = (on.year - a.year) * 12 + (on.month - a.month)
        k = months_apart // months
        while add_months(anchor, k * months) > on_date:
            k -= 1
        while add_months(anchor, (k + 1) * months) <= on_date:
            k += 1
        period_start = add_months(anchor, k * months)
        period_end = add_days(add_months(anchor, (k + 1) * months), -1)

    logged = 0
    counted = 0
    for e in entries:
        parse_iso_date(e.date)
        if not _is_int(e.minutes) or e.minutes < 0:
            raise ValueError("minutes must be a whole number of 0 or more, received %s on %s" % (e.minutes, e.date))
        if period_start <= e.date <= period_end and e.date <= on_date:
            logged += e.minutes
            counted += 1
    shortfall = max(0, required - logged)
    return CpdProgress(
        period_start=period_start,
        period_end=period_end,
        logged_minutes=logged,
        entries_counted=counted,
        required_minutes=required,
        shortfall_minutes=shortfall,
        met=shortfall == 0,
        days_remaining=days_between(on_date, period_end),
    )

Install

fune build

With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 2 dependencies, 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.cpd-hours
Download for Python education.cpd-hours-1.0.0-python.fune · 20,379 bytes sha256 e33baff99928a19fd2059809fb36c81eed3efdf1a6fd27cdc7e60467ef86c55c

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

The whole function, every language, is one file too: education.cpd-hours-1.0.0.fune, 27,361 bytes, sha256 0856b3ae6ff73b1a34e6910f31d9c3658aa73cc73e8d0b31b46eb79c1063ffa3. 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.cpd-hours

after — your function gets the result and the arguments, and returns the final result.

# fune: after education.cpd-hours

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 dates.add-days in education.cpd-hours
# fune: replace dates.add-months in education.cpd-hours

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.cpd-hours --steps.

# fune: step education.cpd-hours 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
an April-March CPD year on 15 March: the first day counts, the day before and a booked course after today do not entries ×5, required minutes 2,100, period months 12, rolling false, anchor 2025-04-01, 2026-03-15 → period start 2025-04-01, period end 2026-03-31, logged minutes 840, entries counted 3, required minutes 2,100, shortfall minutes 1,260, met false, days remaining 16
the requirement met entries ×5, required minutes 800, period months 12, rolling false, anchor 2025-04-01, 2026-03-15 → period start 2025-04-01, period end 2026-03-31, logged minutes 840, entries counted 3, required minutes 800, shortfall minutes 0, met true, days remaining 16
exactly met entries ×5, required minutes 840, period months 12, rolling false, anchor 2025-04-01, 2026-03-15 → period start 2025-04-01, period end 2026-03-31, logged minutes 840, entries counted 3, required minutes 840, shortfall minutes 0, met true, days remaining 16
the new CPD year starts from nothing entries ×5, required minutes 2,100, period months 12, rolling false, anchor 2025-04-01, 2026-04-05 → period start 2026-04-01, period end 2027-03-31, logged minutes 0, entries counted 0, required minutes 2,100, shortfall minutes 2,100, met false, days remaining 360
the last day of a fixed year entries ×5, required minutes 2,100, period months 12, rolling false, anchor 2025-04-01, 2026-03-31 → period start 2025-04-01, period end 2026-03-31, logged minutes 930, entries counted 4, required minutes 2,100, shortfall minutes 1,170, met false, days remaining 0
an anchor in the future still finds the period, counting back entries ×5, required minutes 2,100, period months 12, rolling false, anchor 2027-04-01, 2026-03-15 → period start 2025-04-01, period end 2026-03-31, logged minutes 840, entries counted 3, required minutes 2,100, shortfall minutes 1,260, met false, days remaining 16
a rolling year on 5 April 2026 runs from 6 April 2025 entries ×5, required minutes 2,100, period months 12, rolling true, anchor —, 2026-04-05 → period start 2025-04-06, period end 2026-04-05, logged minutes 750, entries counted 3, required minutes 2,100, shortfall minutes 1,350, met false, days remaining 0
a rolling three years, as for revalidation entries ×5, required minutes 2,100, period months 36, rolling true, anchor —, 2026-03-15 → period start 2023-03-16, period end 2026-03-15, logged minutes 960, entries counted 4, required minutes 2,100, shortfall minutes 1,140, met false, days remaining 0
a rolling year ending on 29 February starts on 1 March entries ×3, required minutes 100, period months 12, rolling true, anchor —, 2024-02-29 → period start 2023-03-01, period end 2024-02-29, logged minutes 75, entries counted 2, required minutes 100, shortfall minutes 25, met false, days remaining 0
monthly periods anchored on the 31st: February's starts on the 28th, March's on the 31st again entries ×2, required minutes 120, period months 1, rolling false, anchor 2025-01-31, 2025-03-15 → period start 2025-02-28, period end 2025-03-30, logged minutes 0, entries counted 0, required minutes 120, shortfall minutes 120, met false, days remaining 15
Show the other 10 tests
CaseArgumentsExpected
an empty log , required minutes 2,100, period months 12, rolling false, anchor 2025-04-01, 2025-06-01 → period start 2025-04-01, period end 2026-03-31, logged minutes 0, entries counted 0, required minutes 2,100, shortfall minutes 2,100, met false, days remaining 303
no requirement is always met , required minutes 0, period months 12, rolling true, anchor —, 2025-06-01 → period start 2024-06-02, period end 2025-06-01, logged minutes 0, entries counted 0, required minutes 0, shortfall minutes 0, met true, days remaining 0
a zero-minute entry counts as an entry and adds nothing entries ×1, required minutes 2,100, period months 12, rolling false, anchor 2025-04-01, 2025-06-01 → period start 2025-04-01, period end 2026-03-31, logged minutes 0, entries counted 1, required minutes 2,100, shortfall minutes 2,100, met false, days remaining 303
negative minutes are an error entries ×1, required minutes 2,100, period months 12, rolling false, anchor 2025-04-01, 2025-06-01 → error: minutes must be a whole number of 0 or more, received -30 on 2025-05-01
fractional minutes are an error entries ×1, required minutes 2,100, period months 12, rolling false, anchor 2025-04-01, 2025-06-01 → error: minutes must be a whole number of 0 or more, received 1.5
a negative requirement is an error , required minutes -1, period months 12, rolling true, anchor —, 2025-06-01 → error: requiredMinutes must be a whole number of 0 or more, received -1
a zero-month period is an error , required minutes 60, period months 0, rolling true, anchor —, 2025-06-01 → error: periodMonths must be a whole number of 1 or more, received 0
a fixed period without an anchor is an error , required minutes 60, period months 12, rolling false, anchor —, 2025-06-01 → error: a fixed period needs an anchor date
a rolling period with an anchor is an error , required minutes 60, period months 12, rolling true, anchor 2025-04-01, 2025-06-01 → error: a rolling period takes no anchor
an impossible entry date is an error entries ×1, required minutes 2,100, period months 12, rolling false, anchor 2025-04-01, 2025-06-01 → error: "2025-02-30" is not a real calendar date

More from the author

- **Fixed periods**: a CPD year (1 April to 31 March, 1 November to 31 October) or a three-year cycle. Give `anchor`, the first day of any one period, and `periodMonths`; the period containing `onDate` is found by counting whole periods from the anchor, forwards or backwards. - **Rolling periods**: "35 hours in the last three years", checked on any date. The period is the `periodMonths` ending on `onDate`: 12 months on 31 March 2026 runs from 1 April 2025.

## Decisions

- **Minutes, not hours.** CPD is logged in hours and half hours and quarter hours; minutes keep every sum exact. 35 hours is 2100. - **Entries after `onDate` do not count yet**, even inside a fixed period: booked courses are not CPD done. Entries before the period start do not count either. - **Period boundaries use `dates.add-months` from the anchor each time**, not step by step, so monthly periods anchored on the 31st start on the 28th (or 29th) of February and then on 31 March again, rather than drifting to the 28th for good. A rolling year ending 29 February 2024 starts on 1 March 2023. - Hours in categories (structured, verifiable, participatory), carry-over and pro-rating for part years are body-specific; filter the log per category and call once for each. - `rolling` with an `anchor`, or fixed without one, is refused rather than guessed.

Files

PathBytes
README.md1,595
impl/python.py2,585
impl/rust.rs4,239
impl/typescript.ts2,491
vectors.json10,617