Laying out a whole programme — a placement for every session, with nothing clashing.
This is a search, not an enumeration, and that is the difference
between it and Agenda.Planner.plan/3. Planning lists the ways
one session could be held; arranging must choose one placement per
session such that every choice is still compatible with every other.
A room taken by the keynote is gone for the workshop.
Three constraints hold a programme together:
No resource is in two places at once. Two placements sharing a resource must not overlap — and must be separated by that resource's
buffer_before/buffer_afterturnaround, if it needs any, whether the neighbour is another session in this programme or a booking that already existed.A track cannot clash with itself. Sessions sharing an audience must not overlap, which is what makes a track a track.
Consecutive track sessions must be reachable. The gap between them must be at least the journey between their rooms — derived from the place tree, not configured.
Placing what fits, when not everything does
By default one unplaceable session fails the programme, which is the
right answer when the programme is a unit. When it is a wish list,
unplaced: :allow asks instead for the fewest sessions left out:
{:partial, layout} = arrange(programme, pool, unplaced: :allow)The result is a Agenda.Layout.t/0 under a :partial tag,
never {:ok, …} — a partial programme presented as finished is worse
than an admitted failure, but a partial programme labelled as
partial is better than no answer at all.
That search is branch-and-bound, which makes it anytime: the
first complete layout is found immediately and later ones only
improve on it, so exhausting the node cap returns the best layout so
far rather than nothing. Agenda.Layout's minimal? says which
you have. A relaxation bound — what the resources could hold at
best — lets the search stop as soon as it matches, so a badly
overbooked programme is cheaper to answer than a marginal one.
Preferring one workable layout over another
Every constraint above is hard. A Agenda.Preference is soft: it
never makes a layout invalid, only worse. Declaring one changes what
arrange/3 returns among the answers that were always allowed.
{:ok, programme} = Agenda.prefer(programme, :room_changes, weight: 10)Optimisation is lexicographic and two-pass. The first pass
ignores preferences and proves how many sessions can be placed. The
second takes that number as a hard ceiling and looks only for a
better-scoring layout that places exactly as many — so a preference
can never cost a placement, and Agenda.Layout's minimal?
means what it always meant.
What is not promised is soft optimality: the scoring pass has its
own :score_nodes budget and stops when it runs out, which
score_proven? reports. Proving a weighted optimum needs a bound on
remaining cost that this search has no cheap way to compute, and a
programme that genuinely needs one wants a solver.
Holding placements still
A published programme gets edited, and the edit must not move the
keynote that has already been announced. :pinned fixes chosen
placements and searches around them; every constraint still applies
to a pin, so the sessions that move must work with the ones that
cannot.
Scale, and where this stops
The search is depth-first with backtracking, ordered most-constrained first, and bounded by an explicit node cap. Two things decide how far it reaches.
Sessions that cannot constrain each other are solved apart. A
shared resource, a shared track or a precedence is what carries a
constraint between two sessions; without one of those they are
independent, and searching them together costs the product of their
choices where it should cost the sum. A conference whose days share
no room splits into one subproblem per day. This is exact — the
components are disjoint, so no layout is lost and minimal? still
means proven.
The caps scale with the programme. Interchangeable sessions
receive the same ranked candidates, so a :candidates cap below the
session count makes a satisfiable programme unsatisfiable, and the
work per session grows with the programme, so a fixed :nodes cap
does the same. Both now grow with the session count, and the
placements offered are spread across the window rather than taken
from the front of it. A fixed cap of either kind does not narrow the
search — it reports a workable programme as impossible.
Independent work is done at the same time. Enumerating one
session's placements cannot affect another's, and neither can
searching two disjoint components, so both are spread across
:concurrency processes. Order is preserved, so the answer does not
depend on which scheduler finished first.
What that adds up to on defaults: 1,200 sessions across twenty days
lay out in about a second, 240 across six days in about seventy
milliseconds, and 200 competing for a single day — one component,
so nothing to divide — in about 240 milliseconds. The shape of
the programme matters more than its size: sessions that cannot
interact are nearly free, and sessions that all compete for the same
rooms are the real cost. Saying "no" stays fast either way, since an
impossible programme is cut off by the relaxation bound long before
any cap. Past that, or for a university timetable of thousands of
classes, the answer is still a real constraint solver, and the way to
use one here is to write its output back through
Agenda.Ledger.allocate/2, which stays authoritative either way.
When a cap is reached the result says so rather than returning a partial layout as though it were complete, and says which cap — running out of nodes and running out of placements need opposite responses from the caller.
Summary
Functions
Find a placement for every session in programme.
The smallest set of sessions in programme that cannot all be held.
true when two placements cannot both stand.
Resolve a programme's track reachability durations once.
Types
@type result() :: {:ok, [Agenda.Arrangement.t()]} | {:partial, Agenda.Layout.t()} | {:error, Agenda.Infeasible.t()}
The outcome of arranging a programme.
Functions
@spec arrange(Agenda.Programme.t(), [Agenda.Resource.t()], keyword()) :: result()
Find a placement for every session in programme.
Arguments
programmeis aAgenda.Programme.t/0.poolis the list ofAgenda.Resource.t/0to choose from.
Options
:busyis a map of resource name to what already claims it, asAgenda.Ledger.busy/2returns. The default is%{}. It must not include the claims of:pinnedsessions — those are added for you, so passAgenda.Ledger.busy/2the pinned session names as:except.:pinnedis a list ofAgenda.Arrangement.t/0whose placements are fixed. Those sessions are not searched for, every constraint still applies to them, and they are returned alongside the sessions that were placed. The default is[].:unplaceddecides what happens when a session cannot be held —:error(the default) fails the whole programme,:allowleaves out as few sessions as the search can manage and returns{:partial, layout}.:candidatescaps how many placements are considered per session. The default scales with the programme —40, or ten more than the number of sessions, whichever is larger. Interchangeable sessions are offered the same ranked placements, so a cap below the session count makes a satisfiable programme unsatisfiable.:nodescaps how many search steps are taken across the whole call, including every round of theunplaced: :allowsearch and every independent subproblem the programme splits into. The default scales with the programme —10_000, or 250 per session, whichever is larger — because the work per session grows with the programme and a fixed cap reports a satisfiable one as impossible.:concurrencyis how many processes may work at once, defaulting toSystem.schedulers_online/0. Candidate enumeration and independent subproblems are both spread across them. Pass1to stay on the calling process — what you want when the caller already runs this inside a pool of its own. The answer does not depend on it: results are collected in order, so the same programme arranges the same way at any setting.:travelis passed toAgenda.travel_time/3for the reachability check — use it to supply per-pair overrides.
Returns
{:ok, arrangements}— one per session, mutually consistent, in programme order; or{:partial, t:Agenda.Layout.t/0}underunplaced: :allow, when some sessions could not be held; or{:error, t:Agenda.Infeasible.t/0}naming the session that could not be placed, reporting a bad pin, or reporting that the cap was hit.
Examples
iex> room = Agenda.resource("Hall", seats: 100)
iex> {:ok, room} = Agenda.open(room, "2026-09-15T09:00:00/2026-09-15T12:00:00")
iex> talk = fn name ->
...> Agenda.session(name, duration: "PT1H", window: "2026-09-15/2026-09-16")
...> |> Agenda.Session.needs(:room, seats: 100)
...> end
iex> programme =
...> Agenda.programme("Conf")
...> |> Agenda.Programme.add_track(
...> Agenda.track("Elixir", of: [talk.("Keynote"), talk.("Deep dive")]))
iex> {:ok, arrangements} = Agenda.Arranger.arrange(programme, [room])
iex> Enum.map(arrangements, & &1.session)
["Keynote", "Deep dive"]
@spec conflict(Agenda.Programme.t(), [Agenda.Resource.t()], keyword()) :: {:ok, [String.t()]} | :none
The smallest set of sessions in programme that cannot all be held.
This is the diagnostic to reach for when arrange/3 fails. A failure
names a session that could not be placed; this names the group that
is actually in tension, so that the answer is "any two of these three
fit — choose which one moves" rather than "no arrangement found".
It works by arranging smaller and smaller parts of the programme, so it costs a number of arrangements logarithmic in the programme's size. Run it on failure, not on every call.
Pinned sessions form the background: they are never named as part of
a conflict, because they are not free to move. If the pins alone
cannot be arranged the result is {:ok, []}, which says exactly
that.
Arguments
programmeis aAgenda.Programme.t/0.poolis the list ofAgenda.Resource.t/0to choose from.
Options
Takes the same options as arrange/3. :unplaced is ignored — a
conflict is only meaningful against the all-or-nothing question.
Returns
:nonewhen the whole programme can be arranged and there is nothing to explain; or{:ok, session_names}— a minimal set of sessions that cannot all be held. Removing any one of them leaves a set that can.
Examples
iex> room = Agenda.resource("Hall", seats: 100)
iex> {:ok, room} = Agenda.open(room, "2026-09-15T09:00:00/2026-09-15T10:00:00")
iex> talk = fn name ->
...> Agenda.session(name, duration: "PT1H", window: "2026-09-15/2026-09-16")
...> |> Agenda.Session.needs(:room, seats: 100)
...> end
iex> programme =
...> Agenda.programme("Conf")
...> |> Agenda.Programme.add_session(talk.("Keynote"))
...> |> Agenda.Programme.add_session(talk.("Deep dive"))
iex> Agenda.Arranger.conflict(programme, [room])
{:ok, ["Keynote", "Deep dive"]}
@spec conflict?( Agenda.Arrangement.t(), Agenda.Arrangement.t(), Agenda.Programme.t(), keyword() ) :: boolean()
true when two placements cannot both stand.
Two placements conflict when they share a resource at overlapping
times, when they belong to the same track and overlap, or when a
delegate could not walk between them in the gap. This is the whole
of what arrange/3 enforces between any pair, exposed so that
another solver can be handed the same question and give an answer
this library agrees with.
Capacity beyond one is not a pairwise property — three placements
can each be fine with the other two and still exceed a concurrency
of two — so a caller relying on this to build a model must handle
concurrency > 1 itself.
Arguments
aandbare each aAgenda.Arrangement.t/0.programmeis theAgenda.Programme.t/0they belong to, consulted for their tracks.
Options
:travelis passed toAgenda.travel_time/3.
Returns
truewhen the two cannot both stand.
Examples
iex> import Tempo.Sigils
iex> room = Agenda.resource("Hall")
iex> a = %Agenda.Arrangement{session: "A", allocations: %{room: [room]},
...> interval: ~o"2026-09-15T09:00:00/2026-09-15T10:00:00"}
iex> b = %Agenda.Arrangement{session: "B", allocations: %{room: [room]},
...> interval: ~o"2026-09-15T09:30:00/2026-09-15T10:30:00"}
iex> Agenda.Arranger.conflict?(a, b, Agenda.programme("Conf"))
true
@spec readable(Agenda.Programme.t()) :: {:ok, Agenda.Programme.t()} | {:error, Agenda.Infeasible.t()}
Resolve a programme's track reachability durations once.
Agenda.Track.reachable/2 accepts a duration written as a string,
and the search compares durations thousands of times, so the pattern
is read once here rather than re-parsed per comparison. arrange/3
does this for itself; another solver building on
conflict?/4 must do it too, or a string will reach the comparison
as a FunctionClauseError several frames down.
Arguments
programmeis aAgenda.Programme.t/0.
Returns
{:ok, programme}with every track's reach resolved; or{:error, t:Agenda.Infeasible.t/0}when one is not a duration.
Examples
iex> {:ok, programme} = Agenda.Arranger.readable(Agenda.programme("Conf"))
iex> programme.tracks
[]