Working out when and where a session could be held.
The pipeline is ordered cheapest-first, and every stage but one is set algebra Tempo already performs:
Eligibility — attribute matching, no calendar involved. This removes most of the search space before any interval is touched, and it is where a resource's own
requiresare folded in.Availability — one
Tempo.difference/2per surviving resource: what it is open for, minus what already claims it.Co-availability —
Tempo.intersection/2across a candidate combination: when the room and everyone needed are all free.Slotting — cut those windows into placements of the session's length with
Tempo.IntervalSet.slots/3.Ranking — score against the session's soft preferences and return best-first.
Provenance rides on the intervals themselves. Each resource's free
time is tagged with its own name, and Tempo.intersection/3's
{:merge, fun} resolver accumulates those tags as the sets are
intersected — so a surviving window states which resources produced
it, and Tempo.IntervalSet.slots/3 carries that through to every
placement. Nothing has to be reconstructed afterwards.
Summary
Functions
The smallest set of demands that rules out every way of holding
session.
Rank the ways session could be held against pool.
Functions
@spec conflict(Agenda.Session.t(), [Agenda.Resource.t()], keyword()) :: {:ok, [{:needs | :requires, {atom() | String.t(), atom()}}]} | :none
The smallest set of demands that rules out every way of holding
session.
Where plan/3's failure says that nothing fits, this says which
demands together make it so — the requirement-level counterpart of
Agenda.Arranger.conflict/3. Two attributes that are each
satisfiable alone but impossible together are the common case, and
neither Agenda.explain/2 nor a list of near misses will show it.
Both kinds of demand are searched, which matters because the second is invisible at the call site:
{:needs, role, attribute}— an attribute the session asked for, as inAgenda.Session.needs(session, :room, seats: at_least(8)).{:requires, resource, attribute}— an attribute a rostered resource induces, as in a person whoserequires:tightens whatever room they are booked into.
Arguments
sessionis aAgenda.Session.t/0.poolis the list ofAgenda.Resource.t/0to choose from.
Options
Takes the same options as plan/3.
Returns
:nonewhen the session can be held and there is nothing to explain; or{:ok, demands}— a minimal set of demands that cannot all be met. An empty list means no demand is to blame: the session fails on time alone, with every attribute demand dropped.
Examples
iex> import Agenda.Predicate
iex> small = Agenda.resource("Snug", seats: 4, video_conferencing: true)
iex> {:ok, small} = Agenda.open(small, "2026-06-15T09:00:00/2026-06-15T12:00:00")
iex> big = Agenda.resource("Barn", seats: 40, video_conferencing: false)
iex> {:ok, big} = Agenda.open(big, "2026-06-15T09:00:00/2026-06-15T12:00:00")
iex> session =
...> Agenda.session("Review", duration: "PT1H", window: "2026-06-15/2026-06-16")
...> |> Agenda.Session.needs(:room, seats: at_least(8), video_conferencing: true)
iex> Agenda.Planner.conflict(session, [small, big])
{:ok, [needs: {:room, :seats}, needs: {:room, :video_conferencing}]}
@spec plan(Agenda.Session.t(), [Agenda.Resource.t()], keyword()) :: {:ok, [Agenda.Arrangement.t()]} | {:error, Agenda.Infeasible.t()}
Rank the ways session could be held against pool.
Arguments
sessionis aAgenda.Session.t/0with a duration and a window.poolis the list ofAgenda.Resource.t/0available to choose from.
Options
:busyis a map of resource name to what already claims it — any valueAgenda.Availability.free/2accepts. The default is%{}.:limitis the most arrangements to return. The default is20. Truncation is reported rather than silent — see:truncated?on the result.:spreadchooses how the list is truncated.false, the default, keeps the best:limitplacements, which is what a caller picking one time wants.truesamples start moments evenly across the window instead and round-robins between them, trading depth at the front of the window for coverage of all of it — which is whatAgenda.Arranger.arrange/3needs, since sessions handed identical placements collide. The coverage is what makes a cap safe to set: with:spread,limit: 4over two days offers both days rather than four times on the first.
Returns
{:ok, arrangements}ranked best-first; or{:error, t:Agenda.Infeasible.t/0}carrying the reasons — which includes a session that never said when it may be held.
Examples
iex> session = Agenda.session("Review", duration: "PT1H")
iex> {:error, reason} = Agenda.Planner.plan(session, [])
iex> Agenda.explain(reason)
"Review cannot be held: no window — say when it may be held, with window: on the session or by arranging it inside a programme"
iex> boardroom = Agenda.Resource.new("Boardroom", seats: 8)
iex> {:ok, boardroom} = Agenda.open(boardroom, "2026-06-15T09:00:00/2026-06-15T12:00:00")
iex> session =
...> Agenda.session("Review", duration: "PT1H", window: "2026-06-15/2026-06-16")
...> |> Agenda.Session.needs(:room, seats: 8)
iex> {:ok, arrangements} = Agenda.Planner.plan(session, [boardroom])
iex> length(arrangements)
3