Agenda.Planner (Agenda v0.1.0)

Copy Markdown View Source

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:

  1. 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 requires are folded in.

  2. Availability — one Tempo.difference/2 per surviving resource: what it is open for, minus what already claims it.

  3. Co-availabilityTempo.intersection/2 across a candidate combination: when the room and everyone needed are all free.

  4. Slotting — cut those windows into placements of the session's length with Tempo.IntervalSet.slots/3.

  5. 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

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

@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 in Agenda.Session.needs(session, :room, seats: at_least(8)).

  • {:requires, resource, attribute} — an attribute a rostered resource induces, as in a person whose requires: tightens whatever room they are booked into.

Arguments

Options

Takes the same options as plan/3.

Returns

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

plan(session, pool, options \\ [])

@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

Options

  • :busy is a map of resource name to what already claims it — any value Agenda.Availability.free/2 accepts. The default is %{}.

  • :limit is the most arrangements to return. The default is 20. Truncation is reported rather than silent — see :truncated? on the result.

  • :spread chooses how the list is truncated. false, the default, keeps the best :limit placements, which is what a caller picking one time wants. true samples 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 what Agenda.Arranger.arrange/3 needs, since sessions handed identical placements collide. The coverage is what makes a cap safe to set: with :spread, limit: 4 over 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