Functional Weave
Code in Python

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.

def timetable_clashes(sessions: Sequence[TimetableSession]) -> List[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

@dataclass(frozen=True)
class TimetableSession:
    """One timetabled session."""

    #: unique within the timetable
    id: str
    #: day of the timetable cycle, 1 or more: 1-5 for a week, 1-10 for a two-week timetable
    day: int
    #: minutes after midnight, 0 to 1439
    start: int
    #: minutes after midnight, after start, up to 1440; the session is over at this minute
    end: int
    rooms: List[str]
    teachers: List[str]
    #: classes, sets or cohorts of students
    groups: List[str]

@dataclass(frozen=True)
class TimetableClash:
    """Two sessions that overlap and share something."""

    #: id of the session earlier in the input
    first: str
    #: id of the later one
    second: str
    day: int
    #: first minute of the overlap
    start: int
    #: end of the overlap
    end: int
    #: rooms both sessions use, in the first session's order
    rooms: List[str]
    #: teachers both sessions have
    teachers: List[str]
    #: student groups in both
    groups: List[str]

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

from fune.education.timetable_clash import timetable_clashes  # education.timetable-clash@^1
impl/python.py · 56 lines · open · raw
from typing import List, Sequence

from .education_timetable_clash_types import TimetableClash, TimetableSession


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


def _shared(a: Sequence[str], b: Sequence[str]) -> List[str]:
    out: List[str] = []
    for x in a:
        if x in b and x not in out:
            out.append(x)
    return out


def timetable_clashes(sessions: Sequence[TimetableSession]) -> List[TimetableClash]:
    """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."""
    ids = set()
    for s in sessions:
        if s.id in ids:
            raise ValueError('session id "%s" appears twice' % (s.id,))
        ids.add(s.id)
        if not _is_int(s.day) or s.day < 1:
            raise ValueError('day must be a whole number of 1 or more, received %s for "%s"' % (s.day, s.id))
        if not _is_int(s.start) or s.start < 0 or s.start > 1439:
            raise ValueError('start must be a whole minute from 0 to 1439, received %s for "%s"' % (s.start, s.id))
        if not _is_int(s.end) or s.end <= s.start or s.end > 1440:
            raise ValueError(
                'end must be a whole minute after start and no later than 1440, received %s for "%s"' % (s.end, s.id)
            )
    found = []
    for i in range(len(sessions)):
        for j in range(i + 1, len(sessions)):
            a = sessions[i]
            b = sessions[j]
            if a.day != b.day:
                continue
            start = max(a.start, b.start)
            end = min(a.end, b.end)
            if start >= end:
                continue
            rooms = _shared(a.rooms, b.rooms)
            teachers = _shared(a.teachers, b.teachers)
            groups = _shared(a.groups, b.groups)
            if len(rooms) + len(teachers) + len(groups) == 0:
                continue
            clash = TimetableClash(
                first=a.id, second=b.id, day=a.day, start=start, end=end, rooms=rooms, teachers=teachers, groups=groups
            )
            found.append((a.day, start, i, j, clash))
    found.sort(key=lambda f: (f[0], f[1], f[2], f[3]))
    return [f[4] for f in found]

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.timetable-clash
Download for Python education.timetable-clash-1.0.0-python.fune · 20,723 bytes sha256 987b891839c3f2aeb268418057d3342facb4f9c7800c5ce5f31d1043e4977585

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

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