# `Agenda.Arranger`
[🔗](https://github.com/elixir-tempo/agenda/blob/v0.3.0/lib/agenda/arranger.ex#L1)

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 `t: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.

# `result`

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

The outcome of arranging a programme.

# `arrange`

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

Find a placement for every session in `programme`.

### Arguments

* `programme` is a `t:Agenda.Programme.t/0`.

* `pool` is the list of `t:Agenda.Resource.t/0` to choose from.

* `options` is a keyword list of options.

### 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 `t: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.

* `:score_nodes` caps the search steps of the second pass, which
  looks for a better-scoring layout once the first has proved how
  many sessions can be placed. The default is `2_000`; a layout that
  stops at it reports `score_proven?: false`.

* `: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-15")
    ...>   |> 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`

```elixir
@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

* `programme` is a `t:Agenda.Programme.t/0`.

* `pool` is the list of `t:Agenda.Resource.t/0` to choose from.

* `options` is a keyword list of options.

### Options

* `:busy`, `:pinned`, `:candidates`, `:nodes`, `:score_nodes`,
  `:concurrency` and `:travel` are as for `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-15")
    ...>   |> 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?`

```elixir
@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

* `a` and `b` are each a `t:Agenda.Arrangement.t/0`.

* `programme` is the `t:Agenda.Programme.t/0` they belong to,
  consulted for their tracks.

* `options` is a keyword list of options.

### Options

* `:travel` is passed to `Agenda.travel_time/3`.

### 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`

```elixir
@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

* `programme` is a `t:Agenda.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
    []

---

*Consult [api-reference.md](api-reference.md) for complete listing*
