education.timetable-clash
Find timetable clashes: overlapping sessions that share a room, a teacher or a student group, in a fixed order.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 20 tests, run in TypeScript, Python and Rust.
What it does
Checks a timetable for double-booking: every pair of sessions on the same day whose times overlap and that share a room, a teacher or a student group. Each clash says which two sessions, when the overlap is and what they share.
## Decisions
For example
timetable_clashes(sessions ×5)→ ×2 a week's morning: the assembly overlap is reported before the room clash, by overlap start; back-to-back lessons do not clashtimetable_clashes()→ no sessions, no clashestimetable_clashes(sessions ×1)→ one session cannot clash
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 timetable_clashes(sessions: &[TimetableSession]) -> Vec<TimetableClash>
| sessions | TimetableSession[] | every session in the timetable, in any order |
| returns | TimetableClash[] | one entry per clashing pair, by day, then overlap start, then input position |
The types it declares, generated into your project
/// One timetabled session.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TimetableSession {
/// unique within the timetable
pub id: String,
/// day of the timetable cycle, 1 or more: 1-5 for a week, 1-10 for a two-week timetable
pub day: i64,
/// minutes after midnight, 0 to 1439
pub start: i64,
/// minutes after midnight, after start, up to 1440; the session is over at this minute
pub end: i64,
pub rooms: Vec<String>,
pub teachers: Vec<String>,
/// classes, sets or cohorts of students
pub groups: Vec<String>,
}
/// Two sessions that overlap and share something.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TimetableClash {
/// id of the session earlier in the input
pub first: String,
/// id of the later one
pub second: String,
pub day: i64,
/// first minute of the overlap
pub start: i64,
/// end of the overlap
pub end: i64,
/// rooms both sessions use, in the first session's order
pub rooms: Vec<String>,
/// teachers both sessions have
pub teachers: Vec<String>,
/// student groups in both
pub groups: Vec<String>,
}
Your code names it in one line, in the file that uses it
fune!(education.timetable-clash@^1); // then call timetable_clashes(…)
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
fn shared(a: &[String], b: &[String]) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for x in a {
if b.contains(x) && !out.contains(x) {
out.push(x.clone());
}
}
out
}
/// Every pair of sessions that overlap on the same day and share a room,
/// teacher or group. Times are half-open: a session ending at 600 does not
/// clash with one starting at 600.
///
/// # Panics
/// Panics on a repeated id, a day below 1, or a start or end out of range.
pub fn timetable_clashes(sessions: &[TimetableSession]) -> Vec<TimetableClash> {
for (k, s) in sessions.iter().enumerate() {
if sessions[..k].iter().any(|o| o.id == s.id) {
panic!("session id \"{}\" appears twice", s.id);
}
if s.day < 1 {
panic!("day must be a whole number of 1 or more, received {} for \"{}\"", s.day, s.id);
}
if s.start < 0 || s.start > 1439 {
panic!("start must be a whole minute from 0 to 1439, received {} for \"{}\"", s.start, s.id);
}
if s.end <= s.start || s.end > 1440 {
panic!(
"end must be a whole minute after start and no later than 1440, received {} for \"{}\"",
s.end, s.id
);
}
}
let mut found: Vec<(i64, i64, usize, usize, TimetableClash)> = Vec::new();
for i in 0..sessions.len() {
for j in (i + 1)..sessions.len() {
let (a, b) = (&sessions[i], &sessions[j]);
if a.day != b.day {
continue;
}
let start = a.start.max(b.start);
let end = a.end.min(b.end);
if start >= end {
continue;
}
let rooms = shared(&a.rooms, &b.rooms);
let teachers = shared(&a.teachers, &b.teachers);
let groups = shared(&a.groups, &b.groups);
if rooms.is_empty() && teachers.is_empty() && groups.is_empty() {
continue;
}
found.push((
a.day,
start,
i,
j,
TimetableClash {
first: a.id.clone(),
second: b.id.clone(),
day: a.day,
start,
end,
rooms,
teachers,
groups,
},
));
}
}
found.sort_by(|x, y| (x.0, x.1, x.2, x.3).cmp(&(y.0, y.1, y.2, y.3)));
found.into_iter().map(|f| f.4).collect()
}
fn whole(value: &Value, message: &str, id: &str) -> i64 {
match value {
Value::Float(f) if f.fract() != 0.0 => panic!("{}, received {} for \"{}\"", message, f, id),
_ => value.as_i64(),
}
}
fn strings(value: &Value) -> Vec<String> {
value.as_arr().iter().map(|v| v.as_str().to_string()).collect()
}
fn strings_to_value(list: &[String]) -> Value {
Value::Arr(list.iter().map(|s| Value::str(s)).collect())
}
pub fn timetable_clash_to_value(c: &TimetableClash) -> Value {
Value::obj(vec![
("first", Value::str(&c.first)),
("second", Value::str(&c.second)),
("day", Value::Int(c.day)),
("start", Value::Int(c.start)),
("end", Value::Int(c.end)),
("rooms", strings_to_value(&c.rooms)),
("teachers", strings_to_value(&c.teachers)),
("groups", strings_to_value(&c.groups)),
])
}
pub fn fune_vector(args: &[Value]) -> Value {
let sessions: Vec<TimetableSession> = args[0]
.as_arr()
.iter()
.map(|v| {
let id = v.get("id").as_str().to_string();
TimetableSession {
day: whole(v.get("day"), "day must be a whole number of 1 or more", &id),
start: whole(v.get("start"), "start must be a whole minute from 0 to 1439", &id),
end: whole(v.get("end"), "end must be a whole minute after start and no later than 1440", &id),
rooms: strings(v.get("rooms")),
teachers: strings(v.get("teachers")),
groups: strings(v.get("groups")),
id,
}
})
.collect();
Value::Arr(timetable_clashes(&sessions).iter().map(timetable_clash_to_value).collect())
}Install
fune build
With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and nothing else, 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.timetable-clash
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./education.timetable-clash-1.0.0-rust.fune, or fetch it from a terminal with fune pull education.timetable-clash@1.0.0:rust.
The whole function, every language, is one file too: education.timetable-clash-1.0.0.fune, 27,523 bytes, sha256 8f9e99aff4a89eb9b167b91d4020da0439b1ef2d0c7b9adcd83173db26430efb. 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.timetable-clash
after — your function gets the result and the arguments, and returns the final result.
// fune: after education.timetable-clash
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.timetable-clash --steps.
// fune: step education.timetable-clash 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 week's morning: the assembly overlap is reported before the room clash, by overlap start; back-to-back lessons do not clash | sessions ×5 | → | ×2 |
| no sessions, no clashes | → | ||
| one session cannot clash | sessions ×1 | → | |
| a session ending at 600 and one starting at 600 in the same room do not clash | sessions ×2 | → | |
| one minute of overlap is a clash on everything shared | sessions ×2 | → | ×1 |
| the same resources on different days do not clash | sessions ×2 | → | |
| a long session containing a short one: the overlap is the short one | sessions ×2 | → | ×1 |
| overlapping in time but sharing nothing is fine | sessions ×2 | → | |
| team teaching: any one teacher in common is a clash, and shared names follow the first session's order | sessions ×2 | → | ×1 |
| equal overlap starts are ordered by input position | sessions ×3 | → | ×3 |
Show the other 10 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| later days come after earlier ones whatever the input order | sessions ×4 | → | ×2 |
| a session with no rooms, teachers or groups clashes with nothing | sessions ×2 | → | |
| a whole day up to midnight | sessions ×2 | → | ×1 |
| a repeated id is an error | sessions ×2 | → | error: session id "maths-7a" appears twice |
| an end before the start is an error | sessions ×1 | → | error: end must be a whole minute after start and no later than 1440, received 540 for "bad" |
| a zero-length session is an error | sessions ×1 | → | error: end must be a whole minute after start and no later than 1440, received 600 for "bad" |
| an end past midnight is an error | sessions ×1 | → | error: end must be a whole minute after start and no later than 1440, received 1441 for "bad" |
| a negative start is an error | sessions ×1 | → | error: start must be a whole minute from 0 to 1439, received -1 for "bad" |
| a fractional minute is an error | sessions ×1 | → | error: start must be a whole minute from 0 to 1439, received 540.5 for "bad" |
| day 0 is an error | sessions ×1 | → | error: day must be a whole number of 1 or more, received 0 for "bad" |
More from the author
- **Half-open times.** A session from 540 to 600 (09:00-10:00) is over at 600, so one starting at 600 in the same room does not clash with it. Treating back-to-back lessons as clashes is the usual bug; a vector pins it. - **Lists, not single values.** Team teaching, combined groups and split sites mean a session can have several teachers, groups or rooms; any one in common is a clash, and all the shared ones are listed. - **A fixed order**, so the same timetable always gives the same report: by `day`, then the overlap's start, then the first session's position in the input, then the second's. Within a clash, `first` is the session that came earlier in the input, and shared names follow its lists' order. - `day` is a number, so a two-week timetable is days 1 to 10 and anything else (term weeks, rotating days) can be encoded the same way. Times are minutes after midnight; `time.minutes-between` and friends convert. - Every pair is compared: for a school timetable of a few thousand sessions that is fast enough, and simpler to trust than a sweep line.
## Errors
A repeated `id`, `day` below 1, a start outside 0 to 1439, an end not after the start or past 1440, or a fractional minute all raise.
Files
| Path | Bytes |
|---|---|
| README.md | 1,502 |
| impl/python.py | 2,288 |
| impl/rust.rs | 4,326 |
| impl/typescript.ts | 2,196 |
| vectors.json | 11,176 |