A sorted, non-overlapping collection of Tempo.Interval.t/0
values — the multi-interval counterpart to Tempo.Interval.
IntervalSet is the operational form for set operations. Every
AST shape that expands to a disjoint list of bounded spans
(non-contiguous masks, stepped ranges, iterated groups, bounded
recurrences, all-of sets) materialises to an IntervalSet via
Tempo.to_interval/1.
Storage backends
The representation is pluggable through Tempo.IntervalSet.Backend:
the struct carries a :backend module and opaque backend state, and
every function here reaches the members through that contract. The
default is Tempo.IntervalSet.Backend.List — a sorted plain list.
Select a backend at construction with new(intervals, backend: ...);
member-preserving operations keep their operand's backend.
Invariants
The constructor new/1 guarantees:
Intervals are sorted ascending by
from.Adjacent or overlapping intervals are coalesced. Half-open semantics means
[a, b) ++ [b, c) == [a, c)— the coalesce pass merges both overlap and touch cases.No
:undefinedendpoints. (Open-ended intervals cannot participate in a set; the caller must bound them first.)
Counting
A set answers three different "how much" questions, and they return very different numbers — reach for the one that matches intent:
How many windows (member spans)? —
count/1, the set's cardinality.How many sub-points (days, hours, … at the members' resolution)? —
Enum.count/1. TheEnumerableprotocol walks into each member and yields its stepped points, so this totals across every window, not the number of windows.How long (elapsed time)? —
Tempo.duration/1on the whole set (the members' total) or on a member, orTempo.at_least?/2to keep only windows of a given length. Never count sub-points for this: across a DST boundary the walk skips the spring-forward hour and emits the fall-back hour twice, soEnum.countdeliberately diverges from elapsed time.
A two-window set — January and March — is 2 windows but 62
sub-points (31 + 31 days):
iex> {:ok, free} = Tempo.union(~o"2026Y1M", ~o"2026Y3M")
iex> Tempo.IntervalSet.count(free)
2
iex> Enum.count(free)
62Timezone handling
An IntervalSet preserves the wall-clock + zone form of its member intervals on the struct. Any set operation that needs to compare endpoints across zones derives a UTC projection on demand; no UTC cache is stored on the struct. This keeps results stable when the zone database updates — re-running the operation simply uses whatever zone rules are current at the time of the call.
See guides/enumeration-semantics.md for the full discussion of
wall-clock-vs-UTC authority.
Summary
Functions
Whether the set's backend holds a finite member list.
Merge touching or overlapping member intervals into larger
spans, returning a new t/0 in canonical instant-set
form.
Return the number of member intervals in the set.
true when any member interval of set covers point.
Total covered duration of the set — the sum of every member's length.
Whether the set has no member intervals.
Keep only the member intervals for which fun returns true,
returning a new t/0.
The earliest member interval of the set, or nil when the set
is empty.
Construct a lazy, potentially unbounded set from an ordered generator.
Apply fun to each member interval and return the results as
a plain list.
Construct a t/0 from a list of intervals.
Raising version of new/1.
Build the Allen-relation matrix between every member of a
and every member of b.
Cut each member into consecutive fixed-length bookable slots.
Return the member intervals as a plain list.
Total duration covered by the set's members, as a
Tempo.Duration.t/0.
An enumerable yielding the member intervals in time order.
Types
@type t() :: %Tempo.IntervalSet{ backend: module(), intervals: Tempo.IntervalSet.Backend.state(), metadata: map() }
The set struct. :intervals holds the backend's state — for the
default Tempo.IntervalSet.Backend.List that is the member list
itself; other backends store their own representation there. Reach
members through the named accessors (to_list/1, walk/1,
count/1, empty?/1, first/1), never the field.
Functions
Whether the set's backend holds a finite member list.
true on the list and tree backends; false on a lazy generator
backend, where aggregate operations (to_list/1, count/1,
coalesce/1, set algebra) raise Tempo.UnboundedSetError instead
of walking forever.
Arguments
setis at/0.
Returns
truewhen every member can be materialised, otherwisefalse.
Examples
iex> Tempo.IntervalSet.bounded?(Tempo.IntervalSet.new!([]))
true
Merge touching or overlapping member intervals into larger
spans, returning a new t/0 in canonical instant-set
form.
IntervalSet preserves member identity by default — each
interval stays a distinct member with its own metadata. That
shape is right for event management, bookings, and any query
that asks about individual members.
Some questions are about the instants covered by the set, not the members: "is this point covered?", "what's the total duration?", "are these two schedules equivalent?". For those, the canonical instant-set form is the right shape — two touching intervals merge into one, and the set has exactly one member per contiguous covered region.
Under the half-open [from, to) convention, intervals merge
when the later one's from is at or before the earlier one's
to. Touching ([a, b) ++ [b, c) == [a, c)) and overlapping
cases both merge.
Metadata
When two members merge, the earlier member's metadata is kept on the merged span and the later member's is dropped. If metadata matters for your query, filter or project before coalescing.
Arguments
setis at/0.
Returns
- A
t/0with touching and overlapping intervals merged.
Examples
iex> set = Tempo.IntervalSet.new!([
...> %Tempo.Interval{from: ~o"2026-06-15", to: ~o"2026-06-16"},
...> %Tempo.Interval{from: ~o"2026-06-16", to: ~o"2026-06-17"}
...> ])
iex> Tempo.IntervalSet.count(set)
2
iex> coalesced = Tempo.IntervalSet.coalesce(set)
iex> Tempo.IntervalSet.count(coalesced)
1
@spec count(t()) :: non_neg_integer()
Return the number of member intervals in the set.
A named helper so callers never have to write
length(set.intervals) or length(to_list(set)) in
user-facing code.
Arguments
setis at/0.
Returns
- The count of member intervals as a non-negative integer.
Examples
iex> set = Tempo.IntervalSet.new!([
...> %Tempo.Interval{from: ~o"2026-06-01", to: ~o"2026-06-10"},
...> %Tempo.Interval{from: ~o"2026-07-01", to: ~o"2026-07-10"}
...> ])
iex> Tempo.IntervalSet.count(set)
2
true when any member interval of set covers point.
Coalesces internally — a point is "covered" iff it falls inside at least one member span. For the common booking/scheduling question "is this slot occupied?", this is the right predicate.
Arguments
Returns
trueorfalse.
Examples
iex> set = Tempo.IntervalSet.new!([
...> %Tempo.Interval{from: ~o"2026-06-15", to: ~o"2026-06-20"}
...> ])
iex> Tempo.IntervalSet.covered?(set, ~o"2026-06-17")
true
iex> Tempo.IntervalSet.covered?(set, ~o"2026-06-25")
false
@spec duration(t()) :: Tempo.Duration.t()
Total covered duration of the set — the sum of every member's length.
Each member's length is measured on the UTC time line (the same
measurement as Tempo.Interval.duration/1), so DST transitions
inside a member are accounted for. Members are disjoint by
construction when produced by the set operations; if the set was
built with overlapping members deliberately preserved, the overlap
is counted once per member.
Arguments
setis at/0.
Returns
- A
Tempo.Duration.t/0holding the total in seconds. The empty set has a duration of zero seconds.
Examples
iex> {:ok, free} = Tempo.union(~o"2026-06-01T09/2026-06-01T12", ~o"2026-06-01T14/2026-06-01T17")
iex> Tempo.IntervalSet.duration(free)
~o"PT21600S"
Whether the set has no member intervals.
Arguments
setis at/0.
Returns
truewhen the set is empty, otherwisefalse.
Examples
iex> Tempo.IntervalSet.empty?(Tempo.IntervalSet.new!([]))
true
iex> set = Tempo.IntervalSet.new!([
...> %Tempo.Interval{from: ~o"2026-06-01", to: ~o"2026-06-10"}
...> ])
iex> Tempo.IntervalSet.empty?(set)
false
@spec filter(t(), (Tempo.Interval.t() -> as_boolean(any()))) :: t()
Keep only the member intervals for which fun returns true,
returning a new t/0.
Arguments
setis at/0.funis a 1-arity predicate applied to each memberTempo.Interval.t/0.
Returns
- A new
t/0containing only the members wherefunreturned a truthy value. The input's invariants (sorted, coalesced) are preserved — filtering cannot create overlap.
Examples
iex> set = Tempo.IntervalSet.new!([
...> %Tempo.Interval{from: ~o"2026-06-15", to: ~o"2026-06-16"},
...> %Tempo.Interval{from: ~o"2026-06-20", to: ~o"2026-06-25"}
...> ])
iex> long = Tempo.IntervalSet.filter(set, &Tempo.at_least?(&1, ~o"P2D"))
iex> Tempo.IntervalSet.count(long)
1
@spec first(t()) :: Tempo.Interval.t() | nil
The earliest member interval of the set, or nil when the set
is empty.
Members are held in time order, so this is the interval with the
earliest from endpoint.
Arguments
setis at/0.
Returns
- The first
Tempo.Interval.t/0, ornilfor an empty set.
Examples
iex> set = Tempo.IntervalSet.new!([
...> %Tempo.Interval{from: ~o"2026-07-01", to: ~o"2026-07-10"},
...> %Tempo.Interval{from: ~o"2026-06-01", to: ~o"2026-06-10"}
...> ])
iex> Tempo.IntervalSet.first(set).from
~o"2026Y6M1D"
iex> Tempo.IntervalSet.first(Tempo.IntervalSet.new!([]))
nil
@spec from_stream( Enumerable.t(), keyword() ) :: t()
Construct a lazy, potentially unbounded set from an ordered generator.
The generator is any Enumerable (typically a Stream) yielding
member intervals in time order — sorted by from, disjoint, each
anchored and bounded. An infinite generator cannot be validated up
front, so this contract is the caller's responsibility; members are
checked as they are consumed. See Tempo.IntervalSet.Backend.Lazy
for what a lazy set can and cannot answer.
Arguments
enumerableis the ordered member generator.optionsis a keyword list of options.
Options
:metadatais a map of set-level metadata. The default is%{}.
Returns
- A
t/0on the lazy backend.
Examples
iex> mondays = Stream.iterate(~o"2026-01-05", &Tempo.shift(&1, week: 1))
iex> set = mondays |> Stream.map(&Tempo.to_interval!/1) |> Tempo.IntervalSet.from_stream()
iex> set |> Tempo.IntervalSet.walk() |> Enum.take(2) |> Enum.map(& &1.from.time[:day])
[5, 12]
@spec map(t(), (Tempo.Interval.t() -> any())) :: [any()]
Apply fun to each member interval and return the results as
a plain list.
Unlike the Enumerable protocol for IntervalSet — which
walks each sub-point inside every interval at the next-finer
resolution — map/2 operates on the member intervals
themselves. It's the set-as-sequence-of-spans view.
The result is a plain list, not an IntervalSet, because the mapper may return anything (integers, tuples, arbitrary values).
Arguments
setis at/0.funis a 1-arity function applied to each memberTempo.Interval.t/0.
Returns
- A list of whatever
funreturns, in the set's sort order.
Examples
iex> set = Tempo.IntervalSet.new!([
...> %Tempo.Interval{from: ~o"2026-06-15", to: ~o"2026-06-16"},
...> %Tempo.Interval{from: ~o"2026-06-20", to: ~o"2026-06-21"}
...> ])
iex> Tempo.IntervalSet.map(set, &Tempo.day/1)
[15, 20]
@spec new( [Tempo.Interval.t()], keyword() ) :: {:ok, t()} | {:error, term()}
Construct a t/0 from a list of intervals.
The input list is sorted ascending by from endpoint and
coalesced — adjacent or overlapping intervals are merged under
the half-open [from, to) convention.
Arguments
intervalsis a list ofTempo.Interval.t/0values. Open- ended intervals (from: :undefinedorto: :undefined) are rejected.
Options
:coalescemerges touching or overlapping members into larger spans. The default isfalse— member identity is preserved.:metadatais a map of set-level metadata. The default is%{}.:backendis the storage backend module (seeTempo.IntervalSet.Backend), or a shorthand —:list(the default,Tempo.IntervalSet.Backend.List) or:tree(Tempo.IntervalSet.Backend.Tree, an interval tree for large, query-heavy sets of anchored members).
Returns
{:ok, interval_set}whereinterval_setis at/0, or{:error, reason}when an input interval is open-ended or otherwise cannot participate in a set.
Examples
iex> {:ok, a} = Tempo.to_interval(~o"2022Y1M")
iex> {:ok, b} = Tempo.to_interval(~o"2022Y3M")
iex> {:ok, set} = Tempo.IntervalSet.new([b, a])
iex> Tempo.IntervalSet.count(set)
2
iex> Tempo.IntervalSet.first(set).from.time
[year: 2022, month: 1]
@spec new!( [Tempo.Interval.t()], keyword() ) :: t()
Raising version of new/1.
@spec relation_matrix( t() | Tempo.Interval.t() | Tempo.t(), t() | Tempo.Interval.t() | Tempo.t() ) :: [ {non_neg_integer(), non_neg_integer(), Tempo.Interval.relation() | {:error, term()}} ] | {:error, term()}
Build the Allen-relation matrix between every member of a
and every member of b.
Allen's algebra is defined on pairs of intervals, not sets —
two multi-member sets can relate several different ways
simultaneously. relation_matrix/2 returns the complete
per-pair classification so you can reason about mixed
conflicts, merge logic, or scheduling visualisations.
Arguments
aandbaret/0(single intervals and Tempo points are coerced to single-member sets for convenience).
Returns
[{a_index, b_index, relation}]— one tuple per pair. Indexes are 0-based into each set's.intervalslist. The relation is one ofTempo.Interval.relation/0.{:error, reason}when either input can't be reduced to an IntervalSet of bounded intervals.
Examples
iex> a = Tempo.IntervalSet.new!([
...> %Tempo.Interval{from: ~o"2026-06-01", to: ~o"2026-06-03"},
...> %Tempo.Interval{from: ~o"2026-06-05", to: ~o"2026-06-07"}
...> ], coalesce: false)
iex> b = Tempo.IntervalSet.new!([
...> %Tempo.Interval{from: ~o"2026-06-04", to: ~o"2026-06-06"}
...> ], coalesce: false)
iex> Tempo.IntervalSet.relation_matrix(a, b)
[{0, 0, :precedes}, {1, 0, :overlapped_by}]
@spec slots(t() | Tempo.Interval.t(), Tempo.Duration.t(), keyword()) :: t()
Cut each member into consecutive fixed-length bookable slots.
Turns a region of free time into the discrete slots you could actually
book within it. Within each member interval, slots of length
duration are emitted starting at the member's start and spaced by
:every (a slot every duration by default — back-to-back,
non-overlapping); a slot is kept only if it fits entirely inside the
member. The members stay distinct (the result is not coalesced).
This is the slicing counterpart to the set operations: where
difference/intersection give you the free regions,
slots/3 cuts those regions into the fixed-length windows a
booking UI offers.
Arguments
setis at/0or a singleTempo.Interval.t/0(as returned by a set operation on a connected result).durationis the slot length, aTempo.Duration.t/0.
Options
:everyis the spacing between slot starts, aTempo.Duration.t/0. Defaults toduration(adjacent slots). A smaller value yields overlapping slots; a larger value leaves gaps. Must be positive.
Returns
- a
t/0of the fitting slots, in order.
Examples
iex> work = Tempo.Interval.new!(from: ~o"2026-06-15T09:00:00", to: ~o"2026-06-15T12:00:00")
iex> work |> Tempo.IntervalSet.slots(~o"PT1H") |> Tempo.IntervalSet.count()
3
iex> work = Tempo.Interval.new!(from: ~o"2026-06-15T09:00:00", to: ~o"2026-06-15T12:00:00")
iex> work |> Tempo.IntervalSet.slots(~o"PT1H", every: ~o"PT30M") |> Tempo.IntervalSet.count()
5
@spec to_list(t()) :: [Tempo.Interval.t()]
Return the member intervals as a plain list.
The Enumerable protocol implementation for an IntervalSet
walks every sub-point inside each interval (consistent with
the Tempo and Tempo.Interval Enumerable implementations —
every Tempo value is a span, iteration walks its sub-points at
the next-finer resolution).
When you want to operate on the member intervals instead
— filter them, count them, map them — to_list/1 gives you
a plain list you can pipe into Enum.
Examples
iex> {:ok, set} = Tempo.IntervalSet.new([
...> %Tempo.Interval{from: ~o"2026-06-01", to: ~o"2026-06-10"},
...> %Tempo.Interval{from: ~o"2026-07-01", to: ~o"2026-07-10"}
...> ])
iex> set |> Tempo.IntervalSet.to_list() |> length()
2Pair with the interval predicates for expressive scheduling:
set
|> Tempo.IntervalSet.to_list()
|> Enum.filter(&Tempo.at_least?(&1, ~o"PT1H"))
@spec total_duration(t()) :: Tempo.Duration.t()
Total duration covered by the set's members, as a
Tempo.Duration.t/0.
Coalesces internally so overlapping members are not
double-counted — the returned duration is the length of the
union of covered instants, not the sum of individual member
durations. For the "sum of member durations" semantics, use
map(set, &Tempo.Interval.duration/1) |> Enum.sum() with
explicit arithmetic.
Arguments
setis at/0.
Returns
Examples
iex> set = Tempo.IntervalSet.new!([
...> %Tempo.Interval{from: ~o"2026-06-15T09:00:00", to: ~o"2026-06-15T10:00:00"},
...> %Tempo.Interval{from: ~o"2026-06-15T11:00:00", to: ~o"2026-06-15T12:00:00"}
...> ])
iex> Tempo.IntervalSet.total_duration(set)
~o"PT7200S"
@spec walk(t()) :: Enumerable.t()
An enumerable yielding the member intervals in time order.
The lazy-safe counterpart to to_list/1: on the default list
backend it is the member list; on a lazy backend it is a stream
that never materialises more members than the caller consumes.
Arguments
setis at/0.
Returns
- An
EnumerableofTempo.Interval.t/0members in time order.
Examples
iex> set = Tempo.IntervalSet.new!([
...> %Tempo.Interval{from: ~o"2026-06-01", to: ~o"2026-06-10"}
...> ])
iex> set |> Tempo.IntervalSet.walk() |> Enum.count()
1