Constraint-based project scheduling over Tempo.Network.
A schedule is a set of tasks — each with a duration — joined by
dependencies (task B starts no earlier than task A finishes) and
bounded by fixed starts and deadlines. solve/1 finds, for every
task, the earliest and latest it can run and whether it sits on the
critical path. This is the classic project-scheduling / critical
path method, expressed as the Simple Temporal Problem Tempo.Network
already solves: tasks are time-periods, dependencies are boundary
relations, and the solver's propagation is the forward/backward pass.
Example
iex> import Tempo.Sigils
iex> {:ok, plan} =
...> Tempo.Schedule.new()
...> |> Tempo.Schedule.task(:design, duration: ~o"P2D", start: ~o"2026-06-01")
...> |> Tempo.Schedule.task(:build, duration: ~o"P3D", after: :design)
...> |> Tempo.Schedule.task(:docs, duration: ~o"P1D", after: :design)
...> |> Tempo.Schedule.task(:ship, duration: ~o"P2D", after: [:build, :docs], deadline: ~o"2026-06-08")
...> |> Tempo.Schedule.solve()
iex> plan[:ship].early
~o"2026Y6M6D/7D"
iex> plan[:docs].critical?
falseHere design → build/docs → ship, with ship due by the 8th. The solver schedules ship for the 6th to the 8th, and finds docs has slack (it is not on the critical path) while design, build, and ship are.
What it does not do
Scheduling around a busy calendar — "fit this task into the first
free gap, avoiding existing meetings" — is a disjunctive problem ("the
task is before that meeting or after it") that lies outside the
Simple Temporal Problem. For that, work with the free regions directly
using the set operations (Tempo.difference/3, Tempo.intersection/3)
and Tempo.IntervalSet.slots/3. Tempo.Schedule is for dependency
scheduling, where the constraints compose by conjunction.
Summary
Functions
The critical path of a solved plan — the task ids with no slack, in start order.
An empty schedule.
Solve the schedule, finding each task's early and late schedule.
The project span of a solved plan — the interval from the earliest task start to the latest task finish.
Add a task to the schedule.
Types
@type t() :: %Tempo.Schedule{network: Tempo.Network.t()}
A schedule under construction.
Functions
@spec critical_path(%{optional(term()) => Tempo.Schedule.ScheduledTask.t()}) :: [ term() ]
The critical path of a solved plan — the task ids with no slack, in start order.
A task is critical when its early and late schedules start together, so any delay to it delays the whole project. Requires a plan with a deadline; without one no task is critical and the list is empty.
Arguments
planis the map returned bysolve/1.
Returns
- the critical task ids, ordered by start.
Examples
iex> import Tempo.Sigils
iex> {:ok, plan} =
...> Tempo.Schedule.new()
...> |> Tempo.Schedule.task(:a, duration: ~o"P2D", start: ~o"2026-06-01")
...> |> Tempo.Schedule.task(:b, duration: ~o"P3D", after: :a, deadline: ~o"2026-06-06")
...> |> Tempo.Schedule.solve()
iex> Tempo.Schedule.critical_path(plan)
[:a, :b]
@spec new() :: t()
An empty schedule.
Returns
- an empty
t/0.
Examples
iex> schedule = Tempo.Schedule.new()
iex> map_size(schedule.network.periods)
0
@spec solve(t()) :: {:ok, %{optional(term()) => Tempo.Schedule.ScheduledTask.t()}} | {:error, :infeasible | Exception.t()}
Solve the schedule, finding each task's early and late schedule.
Arguments
scheduleis at/0.
Returns
{:ok, plan}whereplanis a map ofid => t:Tempo.Schedule.ScheduledTask.t/0;{:error, :infeasible}when the dependencies, durations, and bounds cannot all be satisfied; or{:error, reason}for an option or a valuetask/3could not read.
Examples
iex> import Tempo.Sigils
iex> {:ok, plan} =
...> Tempo.Schedule.new()
...> |> Tempo.Schedule.task(:a, duration: ~o"P2D", start: ~o"2026-06-01")
...> |> Tempo.Schedule.task(:b, duration: ~o"P3D", after: :a)
...> |> Tempo.Schedule.solve()
iex> {plan[:a].early, plan[:b].early}
{~o"2026Y6M1D/2D", ~o"2026Y6M3D/5D"}
iex> import Tempo.Sigils
iex> {:error, %ArgumentError{} = error} =
...> Tempo.Schedule.new()
...> |> Tempo.Schedule.task(:a, duration: ~o"P2D", earliest: ~o"2026-06-01")
...> |> Tempo.Schedule.solve()
iex> Exception.message(error)
"Tempo.Schedule.task/3 takes :not_before where 1.x took :earliest."
@spec span(%{optional(term()) => Tempo.Schedule.ScheduledTask.t()}) :: Tempo.t() | {:error, Exception.t()}
The project span of a solved plan — the interval from the earliest task start to the latest task finish.
Arguments
planis the map returned bysolve/1.
Returns
a
Tempo.t/0, the interval covering the whole project; or{:error, reason}when the plan has no tasks, or no fixed start or:not_beforedate to place them by.
Examples
iex> import Tempo.Sigils
iex> {:ok, plan} =
...> Tempo.Schedule.new()
...> |> Tempo.Schedule.task(:a, duration: ~o"P2D", start: ~o"2026-06-01")
...> |> Tempo.Schedule.task(:b, duration: ~o"P3D", after: :a)
...> |> Tempo.Schedule.solve()
iex> span = Tempo.Schedule.span(plan)
iex> Tempo.Interval.endpoints(span)
{~o"2026Y6M1D", ~o"2026Y6M5D"}
Add a task to the schedule.
An option task/3 does not take, or a value it cannot read, is
recorded on the schedule rather than raised, and solve/1 returns it.
Arguments
scheduleis the schedule to extend.idis any term uniquely identifying the task.optionsis a keyword list of options.
Options
:durationis the task's duration — an exactTempo.Duration.t/0or a{min, max}range.:afteris a task id, or list of ids, this task depends on: it starts no earlier than each of them finishes.:startfixes the task's start on an exact date.:not_beforerequires the task to start on or after a date.:deadlinerequires the task to finish on or before a date.:withinis the window the task lies in, a Tempo value or an interval as every:withinis: the task starts no earlier than the window does and finishes no later than it ends, sowithin: ~o"2026-06"is a task done in June.
Returns
- the schedule with the task added, or with an error recorded.
Examples
iex> import Tempo.Sigils
iex> schedule =
...> Tempo.Schedule.new()
...> |> Tempo.Schedule.task(:a, duration: ~o"P2D")
iex> Map.keys(schedule.network.periods)
[:a]