Agenda.Arranger (Agenda v0.1.0)

Copy Markdown View Source

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_after turnaround, 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

Types

The outcome of arranging a programme.

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

result()

@type result() ::
  {:ok, [Agenda.Arrangement.t()]}
  | {:partial, Agenda.Layout.t()}
  | {:error, Agenda.Infeasible.t()}

The outcome of arranging a programme.

Functions

arrange(programme, pool, options \\ [])

@spec arrange(Agenda.Programme.t(), [Agenda.Resource.t()], keyword()) :: result()

Find a placement for every session in programme.

Arguments

Options

  • :busy is a map of resource name to what already claims it, as Agenda.Ledger.busy/2 returns. The default is %{}. It must not include the claims of :pinned sessions — those are added for you, so pass Agenda.Ledger.busy/2 the pinned session names as :except.

  • :pinned is a list of Agenda.Arrangement.t/0 whose 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 [].

  • :unplaced decides what happens when a session cannot be held — :error (the default) fails the whole programme, :allow leaves out as few sessions as the search can manage and returns {:partial, layout}.

  • :candidates caps 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.

  • :nodes caps how many search steps are taken across the whole call, including every round of the unplaced: :allow search 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.

  • :concurrency is how many processes may work at once, defaulting to System.schedulers_online/0. Candidate enumeration and independent subproblems are both spread across them. Pass 1 to 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.

  • :travel is passed to Agenda.travel_time/3 for 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} under unplaced: :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"]

conflict(programme, pool, options \\ [])

@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

Options

Takes the same options as arrange/3. :unplaced is ignored — a conflict is only meaningful against the all-or-nothing question.

Returns

  • :none when 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"]}

conflict?(a, b, programme, options \\ [])

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

Options

Returns

  • true when 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

readable(programme)

@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

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
[]