education.attendance-rate
Pupil attendance and absence rates in basis points, with the DfE persistent and severe absence flags.
1.0.1 · published 2026-10-03 by charlie · Anterra
Pinned by 19 tests, run in TypeScript, Python and Rust.
What it does
A pupil's attendance and absence rates in basis points (9000 is 90.00%), and whether they are persistently or severely absent under the Department for Education's definitions for schools in England.
## The definitions, and why they are data
For example
attendance_rate(380, 38, 2025-10-01)→ attendance basis points 90%, absence basis points 10%, persistently absent true, severely absent false 38 of 380 sessions missed is exactly 10%: persistently absentattendance_rate(380, 37, 2025-10-01)→ attendance basis points 90.26%, absence basis points 9.74%, persistently absent false, severely absent false 37 of 380 is 9.74%: not persistently absentattendance_rate(2,501, 250, 2025-10-01)→ attendance basis points 90%, absence basis points 10%, persistently absent false, severely absent false 250 of 2,501 rounds to 10.00% but is under 10%: not persistently absent
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.
pub fn attendance_rate(possible_sessions: i64, missed_sessions: i64, on_date: &str) -> AttendanceRate
| possible_sessions | int | sessions the pupil could have attended: two a day, morning and afternoon |
| missed_sessions | int | sessions missed for any reason, authorised or not |
| on_date | date | a date in the period measured, which picks the definitions in force |
| returns | AttendanceRate | rates and flags; the flags use the exact fraction, not the rounded rate |
The type it declares, generated into your project
/// Attendance, absence and the DfE absence flags for one pupil.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AttendanceRate {
/// 10000 minus absenceBasisPoints
pub attendance_basis_points: i64,
/// missed / possible, rounded half-up to a basis point
pub absence_basis_points: i64,
/// missed 10% or more of possible sessions
pub persistently_absent: bool,
/// missed 50% or more; null on a date before that measure was published
pub severely_absent: Option<bool>,
}
Your code names it in one line, in the file that uses it
fune!(education.attendance-rate@^1); // then call attendance_rate(…)
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
use super::funejson::Value; ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::dates_add_days::parse_iso_date; ← from dates.add-days ^1.0.0 · built alongside by fune
use super::education_attendance_rate_data::{
ABSENCE_THRESHOLDS, ABSENCE_THRESHOLDS_HISTORY, ABSENCE_THRESHOLDS_HORIZON,
};
use super::math_round_div::round_div; ← from math.round-div ^1.0.0 · built alongside by fune
fn threshold_on(measure: &str, on_date: &str) -> Option<i64> {
ABSENCE_THRESHOLDS
.iter()
.find(|r| r.measure == measure && on_date >= r.valid_from && r.valid_to.map_or(true, |to| on_date <= to))
.map(|r| r.basis_points)
}
/// Attendance and absence in basis points, and the DfE absence flags.
///
/// The flags compare missed x 10000 with possible x threshold, exactly: a
/// rate of 9.996% rounds to 10.00% but is not persistent absence.
///
/// # Panics
/// Panics on no possible sessions, missed sessions outside 0 to possible, a
/// malformed date, or a date before the persistent absence definition.
pub fn attendance_rate(possible_sessions: i64, missed_sessions: i64, on_date: &str) -> AttendanceRate {
if possible_sessions < 1 {
panic!("possibleSessions must be a whole number of 1 or more, received {}", possible_sessions);
}
if missed_sessions < 0 || missed_sessions > possible_sessions {
panic!(
"missedSessions must be a whole number from 0 to possibleSessions ({}), received {}",
possible_sessions, missed_sessions
);
}
parse_iso_date(on_date);
let persistent = match threshold_on("persistent", on_date) {
Some(bp) => bp,
None => {
if let Some(horizon) = ABSENCE_THRESHOLDS_HORIZON {
if ABSENCE_THRESHOLDS_HISTORY != "full" && on_date < horizon {
panic!(
"no persistent absence definition on {}: this build was installed with history={}, so it only carries rules from {}. Reinstall with history=full for earlier years.",
on_date, ABSENCE_THRESHOLDS_HISTORY, horizon
);
}
}
panic!("no persistent absence definition on {}", on_date)
}
};
let severe = threshold_on("severe", on_date);
let missed = missed_sessions as i128 * 10000;
let possible = possible_sessions as i128;
let absence = round_div(missed_sessions * 10000, possible_sessions, "half-up");
AttendanceRate {
attendance_basis_points: 10000 - absence,
absence_basis_points: absence,
persistently_absent: missed >= possible * persistent as i128,
severely_absent: severe.map(|bp| missed >= possible * bp as i128),
}
}
fn whole(value: &Value, message: &str) -> i64 {
match value {
Value::Float(f) if f.fract() != 0.0 => panic!("{}, received {}", message, f),
_ => value.as_i64(),
}
}
pub fn attendance_rate_to_value(r: &AttendanceRate) -> Value {
Value::obj(vec![
("attendanceBasisPoints", Value::Int(r.attendance_basis_points)),
("absenceBasisPoints", Value::Int(r.absence_basis_points)),
("persistentlyAbsent", Value::Bool(r.persistently_absent)),
("severelyAbsent", match r.severely_absent { Some(b) => Value::Bool(b), None => Value::Null }),
])
}
pub fn fune_vector(args: &[Value]) -> Value {
let possible = whole(&args[0], "possibleSessions must be a whole number of 1 or more");
let missed = whole(
&args[1],
&format!("missedSessions must be a whole number from 0 to possibleSessions ({})", possible),
);
attendance_rate_to_value(&attendance_rate(possible, missed, args[2].as_str()))
}Install
fune build
With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 2 dependencies, pins them in fune.lock, downloads only the Rust 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. A crate’s build.rs runs it before every compile. Or pin a range in fune.project and build in one step:
fune add education.attendance-rate
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./education.attendance-rate-1.0.1-rust.fune, or fetch it from a terminal with fune pull education.attendance-rate@1.0.1:rust.
The whole function, every language, is one file too: education.attendance-rate-1.0.1.fune, 20,274 bytes, sha256 628cb864d801b56920bd73fdf860b8bf0b0145e5019b166523a736ed57a86b22. 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.attendance-rate
after — your function gets the result and the arguments, and returns the final result.
// fune: after education.attendance-rate
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.attendance-rate
// fune: replace math.round-div in education.attendance-rate
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.attendance-rate --steps.
// fune: step education.attendance-rate 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 | |
|---|---|---|---|
| 38 of 380 sessions missed is exactly 10%: persistently absent | 380, 38, 2025-10-01 | → | attendance basis points 90%, absence basis points 10%, persistently absent true, severely absent false |
| 37 of 380 is 9.74%: not persistently absent | 380, 37, 2025-10-01 | → | attendance basis points 90.26%, absence basis points 9.74%, persistently absent false, severely absent false |
| 250 of 2,501 rounds to 10.00% but is under 10%: not persistently absent | 2,501, 250, 2025-10-01 | → | attendance basis points 90%, absence basis points 10%, persistently absent false, severely absent false |
| 190 of 380 is exactly half: severely absent | 380, 190, 2025-10-01 | → | attendance basis points 50%, absence basis points 50%, persistently absent true, severely absent true |
| 189 of 380 is just under half | 380, 189, 2025-10-01 | → | attendance basis points 50.26%, absence basis points 49.74%, persistently absent true, severely absent false |
| no sessions missed | 380, 0, 2025-10-01 | → | attendance basis points 100%, absence basis points 0%, persistently absent false, severely absent false |
| every session missed | 380, 380, 2025-10-01 | → | attendance basis points 0%, absence basis points 100%, persistently absent true, severely absent true |
| a half basis point rounds absence up and attendance is what is left, so they sum to 100% | 20,000, 1, 2025-10-01 | → | attendance basis points 99.99%, absence basis points 0.01%, persistently absent false, severely absent false |
| a single possible session, attended | 1, 0, 2025-10-01 | → | attendance basis points 100%, absence basis points 0%, persistently absent false, severely absent false |
| 2016/17, before severe absence was published: severe is null | 380, 190, 2016-10-01 | → | attendance basis points 50%, absence basis points 50%, persistently absent true, severely absent — |
Show the other 9 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| the last day before severe absence data | 380, 190, 2018-08-31 | → | attendance basis points 50%, absence basis points 50%, persistently absent true, severely absent — |
| the first day of 2018/19 | 380, 190, 2018-09-01 | → | attendance basis points 50%, absence basis points 50%, persistently absent true, severely absent true |
| the first day of the 10% definition | 380, 38, 2015-09-01 | → | attendance basis points 90%, absence basis points 10%, persistently absent true, severely absent — |
| before 2015/16 there is no per-pupil 10% definition | 380, 38, 2015-08-31 | → | error: no persistent absence definition on 2015-08-31 |
| more sessions missed than possible is an error | 380, 381, 2025-10-01 | → | error: missedSessions must be a whole number from 0 to possibleSessions (380), received 381 |
| negative missed sessions is an error | 380, -1, 2025-10-01 | → | error: missedSessions must be a whole number from 0 to possibleSessions (380), received -1 |
| half a session is an error | 380, 1.5, 2025-10-01 | → | error: missedSessions must be a whole number from 0 to possibleSessions (380), received 1.5 |
| no possible sessions is an error | 0, 0, 2025-10-01 | → | error: possibleSessions must be a whole number of 1 or more, received 0 |
| an impossible date is an error | 380, 38, 2025-02-30 | → | error: "2025-02-30" is not a real calendar date |
More from the author
- **Persistent absence**: missing 10% or more of the pupil's own possible sessions. This is the definition from the 2015/16 academic year. It was introduced in 2005/06 at 20%, and from 2010/11 was about 15% measured against a standard number of sessions rather than the pupil's own, which cannot be expressed as a percentage of one pupil's sessions; a date before 1 September 2015 is therefore refused rather than answered with today's rule. - **Severe absence**: missing 50% or more. The data carries it from the 2018/19 academic year, the first year for which the DfE statistics release reports it; before 1 September 2018 `severelyAbsent` is null.
Both are dated rows in `data/absence-thresholds.json`, and `onDate` picks the row in force.
## Decisions
- **The flags use the exact fraction.** A pupil who missed 250 of 2,501 sessions has an absence rate of 9.996%, which rounds to 1000 basis points, but is not persistently absent: 250 is less than 10% of 2,501. Comparing the rounded rate is the naive mistake; a vector pins it. - **Absence is rounded, attendance is derived.** Absence is rounded half-up to a basis point and attendance is 10000 minus that, so the two always add up to 100%. (Rounding both half-up gives 10001 when each sits on a half.) - Sessions are whatever the school counts: two per day (morning and afternoon) in England. Missed sessions include authorised and unauthorised absence, as the DfE measures do; pass only the sessions you want counted. - No possible sessions is an error: there is no rate to report.
## Sources
- Department for Education, "Pupil absence statistics: methodology" (Explore education statistics): definitions of persistent absence (10% or more of a pupil's own possible sessions, from 2015/16) and severe absence (50% or more), and the history of the persistent absence threshold: https://explore-education-statistics.service.gov.uk/methodology/pupil-absence-statistics-methodology - Department for Education, "Pupil absence in schools in England: 2018/19", the first release reporting severe absence: https://explore-education-statistics.service.gov.uk/find-statistics/pupil-absence-in-schools-in-england/2018-19
## Notices
Contains public sector information licensed under the Open Government Licence v3.0 (https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/).
1.0.1 adds its attribution notices (NOTICE). The code and the tests are unchanged.
Files
| Path | Bytes |
|---|---|
| NOTICE | 188 |
| README.md | 2,744 |
| data/absence-thresholds.json | 477 |
| impl/python.py | 2,516 |
| impl/rust.rs | 3,537 |
| impl/typescript.ts | 2,332 |
| vectors.json | 4,449 |