Functional Weave
Code in Rust

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 clash
  • timetable_clashes() → no sessions, no clashes
  • timetable_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>
sessionsTimetableSession[]every session in the timetable, in any order
returnsTimetableClash[]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(…)
impl/rust.rs · 123 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.

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
Download for Rust education.timetable-clash-1.0.0-rust.fune · 22,876 bytes sha256 1c2afec2b6b4961fd6f85572c13c299a34d63538ee1adb52597aa42cc90e43dc

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.

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

PathBytes
README.md1,502
impl/python.py2,288
impl/rust.rs4,326
impl/typescript.ts2,196
vectors.json11,176