# `Tempo.Interval`
[🔗](https://github.com/kipcole9/tempo/blob/v0.20.0/lib/tempo/interval.ex#L1)

An explicit bounded span on the time line.

Every Tempo value *is* an interval at some resolution; a bare
`%Tempo{}` materialises to an `%Tempo.Interval{}` via
`Tempo.to_interval/1`. `%Tempo.Interval{}` carries explicit
`from` and `to` endpoints plus optional recurrence metadata
(`recurrence`, `duration`, `repeat_rule`) for RRULE-style
values.

Tempo uses the half-open `[from, to)` convention: `from` is
inclusive, `to` is exclusive. Adjacent intervals concatenate
cleanly — `[a, b) ++ [b, c) == [a, c)`.

## Comparing intervals

`relation/2` classifies two intervals by Allen's interval
algebra, returning one of 13 mutually-exclusive relations.
See the function docs for the full table.

## Domain semantics: continuous time, discrete-style intervals

Tempo's underlying time line is **continuous**: endpoints project
to gregorian seconds (a real number, via Erlang's
`:calendar.datetime_to_gregorian_seconds/1`) and cross-zone
comparison uses real-number ordering. The set of representable
endpoint positions is dense within Tempo's resolution range
(currently down to one-second granularity).

Interval **boundary semantics** are nonetheless treated in the
discrete style — the `:to` endpoint is *exclusive*, so two
intervals `[a, b)` and `[b, c)` *meet* at the shared boundary `b`
with empty geometric intersection. This is the same convention
Rust's `allen-intervals` crate uses for its discrete integer
domain, and matches Hayes' open-connected-subsets model that
Grüninger and Li cite (TIME 2017, §2.2). It contrasts with the
closed-interval / continuous-domain convention (e.g., Allen's
original 1983 paper, which treats intervals as closed and lets
`meets` happen at a single shared point of inclusion); Tempo's
half-open choice keeps adjacency unambiguous and lets coalescing
be a pure operation on endpoints without a "do these touch?"
predicate.

In practical terms: if you need to model an event that includes
both endpoint moments (a true closed interval), encode it as
`[a, b + ε)` where `ε` is one unit at the value's resolution.
Library code never imposes this — the half-open convention is the
contract.

# `certainty`

```elixir
@type certainty() :: :certain | :possible | :impossible
```

A three-valued relation certainty.

# `interval_like`

```elixir
@type interval_like() :: Tempo.t() | t() | Tempo.IntervalSet.t()
```

Anything `relation/2` can reduce to a single bounded interval.

# `relation`

```elixir
@type relation() ::
  :precedes
  | :meets
  | :overlaps
  | :finished_by
  | :contains
  | :starts
  | :equals
  | :started_by
  | :during
  | :finishes
  | :overlapped_by
  | :met_by
  | :preceded_by
```

One of Allen's 13 interval relations — jointly exhaustive and
pairwise disjoint under the half-open `[from, to)` convention.

# `t`

```elixir
@type t() :: %Tempo.Interval{
  direction: 1 | -1,
  duration: Tempo.Duration.t() | nil,
  from: Tempo.t() | Tempo.Duration.t() | :undefined | nil,
  metadata: map(),
  recurrence: pos_integer() | :infinity,
  repeat_rule: Tempo.t() | nil,
  to: Tempo.t() | :undefined | nil
}
```

# `adjacent?`

```elixir
@spec adjacent?(interval_like(), interval_like()) :: boolean()
```

`true` when the two intervals touch at a single boundary —
either `a` meets `b` or `b` meets `a` (Allen's
`:meets | :met_by`).

### Examples

    iex> Tempo.Interval.adjacent?(~o"2026-06-15", ~o"2026-06-16")
    true

    iex> Tempo.Interval.adjacent?(~o"2026-06-15", ~o"2026-06-17")
    false

# `after?`

```elixir
@spec after?(interval_like(), interval_like()) :: boolean()
```

`true` when `a` starts strictly after `b` ends, with a gap
(Allen's `:preceded_by`).

# `at_least?`

```elixir
@spec at_least?(t(), Tempo.Duration.t()) :: boolean()
```

`true` when the interval is at least as long as the given
duration.

Unbounded intervals (`:undefined` endpoint) satisfy any finite
minimum — an infinite span is trivially "at least" any
duration.

### Examples

    iex> iv = %Tempo.Interval{from: ~o"2026-06-15T09", to: ~o"2026-06-15T11"}
    iex> Tempo.Interval.at_least?(iv, ~o"PT1H")
    true
    iex> Tempo.Interval.at_least?(iv, ~o"PT3H")
    false

# `at_most?`

```elixir
@spec at_most?(t(), Tempo.Duration.t()) :: boolean()
```

`true` when the interval is at most as long as the given
duration.

Unbounded intervals return `false` — an infinite span exceeds
any finite maximum.

### Examples

    iex> iv = %Tempo.Interval{from: ~o"2026-06-15T09", to: ~o"2026-06-15T10"}
    iex> Tempo.Interval.at_most?(iv, ~o"PT1H")
    true
    iex> Tempo.Interval.at_most?(iv, ~o"PT30M")
    false

# `before?`

```elixir
@spec before?(interval_like(), interval_like()) :: boolean()
```

`true` when `a` ends strictly before `b` starts, with a gap
(Allen's `:precedes`). Use `adjacent?/2` to include the
no-gap case.

Returns `false` on any error or non-matching relation.

# `bounded?`

```elixir
@spec bounded?(t()) :: boolean()
```

`true` when both endpoints are concrete `%Tempo{}` values —
neither `:undefined` nor `nil`. Useful as a guard before set
operations or duration checks.

### Examples

    iex> Tempo.Interval.bounded?(%Tempo.Interval{from: ~o"2026-06-01", to: ~o"2026-06-10"})
    true

    iex> Tempo.Interval.bounded?(%Tempo.Interval{from: ~o"2026-06-01", to: :undefined})
    false

# `certainly_after?`

```elixir
@spec certainly_after?(interval_like(), interval_like()) :: boolean()
```

`true` when `a` starts after `b` ends, with a gap (Allen
`:preceded_by`), for *every* placement of their `±` margins. Crisp
counterpart: `after?/2`.

### Examples

    iex> Tempo.Interval.certainly_after?(~o"2010±1Y", ~o"2000±1Y")
    true

# `certainly_before?`

```elixir
@spec certainly_before?(interval_like(), interval_like()) :: boolean()
```

`true` when `a` ends before `b` starts, with a gap (Allen `:precedes`),
for *every* placement of their `±` margins. Crisp counterpart:
`before?/2`.

### Examples

    iex> Tempo.Interval.certainly_before?(~o"2000±1Y", ~o"2010±1Y")
    true

    iex> Tempo.Interval.certainly_before?(~o"2000±1Y", ~o"2001±1Y")
    false

# `certainly_overlaps?`

```elixir
@spec certainly_overlaps?(interval_like(), interval_like()) :: boolean()
```

`true` when `a` and `b` intersect for *every* placement of their `±`
margins — `overlap_certainty(a, b) == :certain`. Crisp counterpart:
`overlaps?/2`.

### Examples

    iex> Tempo.Interval.certainly_overlaps?(~o"2000Y", ~o"2000Y")
    true

    iex> Tempo.Interval.certainly_overlaps?(~o"2000±1Y", ~o"2001±1Y")
    false

# `certainly_within?`

```elixir
@spec certainly_within?(interval_like(), interval_like()) :: boolean()
```

`true` when `a` falls within `b` for *every* placement of their `±`
margins. Crisp counterpart: `within?/2`.

### Examples

    iex> Tempo.Interval.certainly_within?(~o"2000Y6M", ~o"2000Y")
    true

# `compose`

```elixir
@spec compose(relation(), relation()) ::
  [relation()] | {:error, {:invalid_relation, term()}}
```

Compose two Allen relations — the relations possible from `A` to `C` given
`A r1 B` and `B r2 C`.

This is Allen's interval-algebra composition (Allen 1983): a constant-time
read of the 13×13 table. Where `relation/2` compares two intervals you hold,
`compose/2` takes one qualitative step with no interval in hand —
*"if A precedes B and B is during C, how can A relate to C?"* — and returns
every relation some arrangement of the three intervals allows. It is the same
reasoning `Tempo.Network.Solver.relation/3` applies across a whole network,
reduced to a single step.

### Arguments

* `relation1` is the Allen relation from `A` to `B` — one of the 13 atoms
  `relation/2` returns.

* `relation2` is the Allen relation from `B` to `C`.

### Returns

* A list of the relations possible from `A` to `C`, in Allen's canonical
  order — one element when the composition is determined, up to all 13 when
  the step is fully ambiguous.

* `{:error, {:invalid_relation, term}}` when either argument is not one of
  the 13 relation atoms.

### Examples

    iex> Tempo.Interval.compose(:precedes, :during)
    [:precedes, :meets, :overlaps, :starts, :during]

    iex> Tempo.Interval.compose(:equals, :overlaps)
    [:overlaps]

    iex> Tempo.Interval.compose(:precedes, :nonsense)
    {:error, {:invalid_relation, :nonsense}}

# `duration`

```elixir
@spec duration(
  t(),
  keyword()
) :: Tempo.Duration.t() | :infinity
```

Return the interval's length as a `%Tempo.Duration{}` in
seconds. Returns `:infinity` for unbounded intervals (one or
both endpoints `:undefined`).

The result is calendar- and zone-aware — it goes through
`Tempo.Compare.to_utc_seconds/1` so cross-zone intervals
compute a correct wall-clock delta.

### Options

* `:leap_seconds` — when `true`, adds one second to the
  returned duration for each IERS leap-second insertion that
  falls inside `[from, to)`. Defaults to `false` so behaviour
  matches `DateTime`, `Time`, and `:calendar` from Elixir/OTP
  (none of which count leap seconds). See
  `Tempo.Interval.spans_leap_second?/1` and
  `leap_seconds_spanned/1` for detection without arithmetic.

### Examples

    iex> Tempo.Interval.duration(%Tempo.Interval{from: ~o"2026-06-15T09", to: ~o"2026-06-15T10"})
    ~o"PT3600S"

    iex> Tempo.Interval.duration(%Tempo.Interval{from: ~o"2026-06-15", to: :undefined})
    :infinity

    iex> iv = %Tempo.Interval{from: ~o"2016-12-31T23:59:00Z", to: ~o"2017-01-01T00:01:00Z"}
    iex> Tempo.Interval.duration(iv)
    ~o"PT120S"
    iex> Tempo.Interval.duration(iv, leap_seconds: true)
    ~o"PT121S"

# `during?`

```elixir
@spec during?(interval_like(), interval_like()) :: boolean()
```

`true` when `a` is strictly inside `b` — both endpoints of
`a` lie strictly within `b` (Allen's `:during`). Shared-
endpoint cases (`:starts`, `:finishes`) return `false`; use
`within?/2` for the inclusive version.

# `empty?`

```elixir
@spec empty?(t()) :: boolean()
```

`true` when the interval has zero or negative length —
`from == to` (degenerate instant) or `from > to` (inverted
span).

Under the half-open `[from, to)` convention, an interval with
`from >= to` contains no real instants. Empty intervals pass
`bounded?/1` but have no span; inverted intervals are treated
as empty rather than as a span with "negative" duration.

### Examples

    iex> Tempo.Interval.empty?(%Tempo.Interval{from: ~o"2026-06-15", to: ~o"2026-06-15"})
    true

    iex> Tempo.Interval.empty?(%Tempo.Interval{from: ~o"2026-06-20", to: ~o"2026-06-15"})
    true

    iex> Tempo.Interval.empty?(%Tempo.Interval{from: ~o"2026-06-01", to: ~o"2026-06-10"})
    false

# `endpoints`

```elixir
@spec endpoints(t()) :: {Tempo.t() | :undefined, Tempo.t() | :undefined}
```

Return the interval's endpoints as a `{from, to}` tuple.

A named helper so callers never have to reach into the struct
fields in user-facing code.

### Arguments

* `interval` is a `t:t/0`.

### Returns

* `{from, to}` where each endpoint is a `t:Tempo.t/0` or
  `:undefined` for open-ended intervals.

### Examples

    iex> iv = %Tempo.Interval{from: ~o"2026-06-15", to: ~o"2026-06-20"}
    iex> {from, to} = Tempo.Interval.endpoints(iv)
    iex> {Tempo.day(from), Tempo.day(to)}
    {15, 20}

# `equivalent?`

```elixir
@spec equivalent?(t(), t()) :: boolean()
```

`true` when two intervals describe the same temporal extent,
regardless of calendar, zone display, or attached metadata.

Standard `==` compares all struct fields, including `:metadata`,
`:calendar`, and the zone-display details on the endpoints, so the
same span shown in two different zones compares unequal.
`equivalent?/2` projects endpoints to UTC and compares only the
temporal positions — matching the equivalence notion of the
T_bounded_meeting ontology of Grüninger and Li (TIME 2017), under
which intervals are individuated by their position in the structure
of `meets`, not by labels.

Recurrence-related fields (`:recurrence`, `:direction`,
`:repeat_rule`, `:duration`) must match structurally: two recurring
intervals with different rules describe different extents.

### Arguments

* `a` and `b` are `t:t/0` values.

### Returns

* `true` if both intervals occupy the same temporal extent under UTC projection.

* `false` otherwise.

### Examples

    iex> a = %Tempo.Interval{from: ~o"2026-06-15", to: ~o"2026-06-16"}
    iex> b = %Tempo.Interval{from: ~o"2026-06-15", to: ~o"2026-06-16"}
    iex> Tempo.Interval.equivalent?(a, b)
    true

    iex> a = %Tempo.Interval{from: ~o"2026-06-15", to: ~o"2026-06-16"}
    iex> b = %Tempo.Interval{from: ~o"2026-06-15", to: ~o"2026-06-17"}
    iex> Tempo.Interval.equivalent?(a, b)
    false

# `exactly?`

```elixir
@spec exactly?(t(), Tempo.Duration.t()) :: boolean()
```

`true` when the interval's length equals the given duration
exactly.

Unbounded intervals always return `false`.

### Examples

    iex> iv = %Tempo.Interval{from: ~o"2026-06-15T09", to: ~o"2026-06-15T10"}
    iex> Tempo.Interval.exactly?(iv, ~o"PT1H")
    true
    iex> Tempo.Interval.exactly?(iv, ~o"PT2H")
    false

# `from`

```elixir
@spec from(t()) :: Tempo.t() | :undefined
```

Return the interval's `from` endpoint.

A named helper so callers never have to reach into the struct
fields in user-facing code. Compose with `Tempo.day/1`, `Tempo.year/1`,
etc. to extract components of the starting point.

### Arguments

* `interval` is a `t:t/0`.

### Returns

* The `from` endpoint as a `t:Tempo.t/0` or `:undefined` for
  open-ended intervals.

### Examples

    iex> iv = %Tempo.Interval{from: ~o"2026-06-15", to: ~o"2026-06-20"}
    iex> Tempo.Interval.from(iv) |> Tempo.day()
    15

# `inverse_relation`

```elixir
@spec inverse_relation(relation()) :: relation()
```

The inverse Allen relation.

If `relation(a, b)` returns `r`, then `relation(b, a)` returns
`inverse_relation(r)`.

### Examples

    iex> Tempo.Interval.inverse_relation(:contains)
    :during

    iex> Tempo.Interval.inverse_relation(:precedes)
    :preceded_by

    iex> Tempo.Interval.inverse_relation(:equals)
    :equals

# `leap_seconds_spanned`

```elixir
@spec leap_seconds_spanned(t()) :: [{integer(), 1..12, 1..31}]
```

Return the list of IERS leap-second dates that fall inside
`[from, to)`.

### Arguments

* `interval` is a `t:t/0` with both endpoints present.

### Returns

* A list of `{year, month, day}` tuples, each entry drawn from
  `Tempo.LeapSeconds.dates/0`. Empty list when no leap second
  falls inside the span, or when either endpoint is
  `:undefined`.

### Examples

    iex> iv = %Tempo.Interval{from: ~o"2015-01-01", to: ~o"2017-12-31"}
    iex> Tempo.Interval.leap_seconds_spanned(iv)
    [{2015, 6, 30}, {2016, 12, 31}]

# `longer_than?`

```elixir
@spec longer_than?(t(), Tempo.Duration.t()) :: boolean()
```

`true` when the interval's length is strictly greater than
the given duration.

### Examples

    iex> iv = %Tempo.Interval{from: ~o"2026-06-15T09", to: ~o"2026-06-15T11"}
    iex> Tempo.Interval.longer_than?(iv, ~o"PT1H")
    true
    iex> Tempo.Interval.longer_than?(iv, ~o"PT2H")
    false

# `meets?`

```elixir
@spec meets?(interval_like(), interval_like()) :: boolean()
```

`true` when `a`'s end coincides exactly with `b`'s start
(Allen's `:meets`). Under the half-open convention this means
the intervals share no point but have no gap.

# `metadata`

```elixir
@spec metadata(t()) :: map()
```

Return the metadata map attached to the interval.

A named helper so callers never have to reach into the struct
fields in user-facing code. Metadata is free-form and is
preserved across set operations — intervals that survive a
union, intersection, or difference inherit the surviving
operand's metadata, so this accessor is the intended way to
read iCal `SUMMARY`, `LOCATION`, event UIDs, and any other
application-attached per-interval data.

### Arguments

* `interval` is a `t:t/0`.

### Returns

* The metadata map. An interval constructed without metadata
  returns `%{}`.

### Examples

    iex> iv = Tempo.Interval.new!(
    ...>   from: ~o"2026-06-15T09",
    ...>   to:   ~o"2026-06-15T10",
    ...>   metadata: %{summary: "Stand-up"}
    ...> )
    iex> Tempo.Interval.metadata(iv)
    %{summary: "Stand-up"}

    iex> iv = Tempo.Interval.new!(from: ~o"2026-06-15", to: ~o"2026-06-20")
    iex> Tempo.Interval.metadata(iv)
    %{}

# `new`

```elixir
@spec new(keyword()) :: {:ok, t()} | {:error, Exception.t()}
```

Construct a `t:Tempo.Interval.t/0` from a keyword list of options.

The companion to `~o` interval sigils and `Tempo.to_interval/1`.
Use this when you have the endpoints as runtime values (e.g. two
`%Tempo{}` structs) rather than an ISO 8601 string.

At least one of `:from`, `:to`, or `:duration` must be supplied.

### Arguments

* `options` is a keyword list of construction options
  (see below).

### Options

* `:from` is a `t:Tempo.t/0` or the atom `:undefined`
  (open start).

* `:to` is a `t:Tempo.t/0` or the atom `:undefined`
  (open end).

* `:duration` is a `t:Tempo.Duration.t/0`. When combined with
  `:from`, the `:to` endpoint is derived lazily by
  `Tempo.to_interval/1`.

* `:recurrence` is a `pos_integer()` or `:infinity`.

* `:repeat_rule` is a `t:Tempo.RRule.Rule.t/0` or `t:Tempo.t/0`.

* `:metadata` is a free-form map carried through set operations.

### Returns

* `{:ok, t()}` on success.

* `{:error, reason}` when endpoints are invalid, `:from` is not
  strictly earlier than `:to` (a zero-extent interval is not a
  valid interval under the half-open convention; see
  `Tempo.Interval.empty?/1` for the predicate that detects
  malformed struct literals), or required fields are missing.

### Examples

    iex> {:ok, iv} = Tempo.Interval.new(
    ...>   from: Tempo.new!(year: 2026, month: 6, day: 15, hour: 9),
    ...>   to:   Tempo.new!(year: 2026, month: 6, day: 15, hour: 17)
    ...> )
    iex> iv.from.time
    [year: 2026, month: 6, day: 15, hour: 9]

    iex> {:ok, iv} = Tempo.Interval.new(
    ...>   from: Tempo.new!(year: 1985),
    ...>   to: :undefined
    ...> )
    iex> iv.to
    :undefined

# `new`

```elixir
@spec new(Tempo.t(), Tempo.t()) :: {:ok, t()} | {:error, Exception.t()}
```

Construct a bounded `t:t/0` from two concrete endpoints.

The positional companion to `new/1` for the common case — a
closed interval `[from, to)` from two `%Tempo{}` values. Equivalent
to `new(from: from, to: to)`, but reads as the two nouns it is.
For the `:duration`, `:recurrence`, `:repeat_rule`, or `:metadata`
forms — or an open-ended endpoint (`:undefined`) — use the
keyword `new/1`, where the labels earn their place.

### Arguments

* `from` is the start `t:Tempo.t/0` (inclusive).

* `to` is the end `t:Tempo.t/0` (exclusive), and must be strictly
  later than `from`.

### Returns

* `{:ok, interval}` on success.

* `{:error, reason}` when the endpoints are of incompatible
  calendars, or `from` is not strictly earlier than `to`.

### Examples

    iex> {:ok, iv} = Tempo.Interval.new(~o"2026-06-15T09", ~o"2026-06-15T17")
    iex> iv.from.time
    [year: 2026, month: 6, day: 15, hour: 9]

# `new!`

```elixir
@spec new!(keyword()) :: t()
```

Bang variant of `new/1`. Raises on invalid input.

# `new!`

```elixir
@spec new!(Tempo.t(), Tempo.t()) :: t()
```

Bang variant of `new/2`. Raises on invalid input.

### Examples

    iex> iv = Tempo.Interval.new!(~o"2026-06-15T09", ~o"2026-06-15T17")
    iex> iv.to.time
    [year: 2026, month: 6, day: 15, hour: 17]

# `next_unit_boundary`

Given a fully-resolved `%Tempo{}`, compute the two endpoints of
its implicit span under the half-open `[from, to)` convention.

Returns `{:ok, {lower, upper}}` where both are `%Tempo{}` values,
or `{:error, reason}` when the input has no finer unit that could
produce a bounded span (e.g. a fully-specified second-resolution
datetime).

The lower bound is the input's time extended with the minimum of
the next-finer unit (so `[year: 2022]` becomes `[year: 2022,
month: 1]` on a month-based calendar). The upper bound is the
lower bound incremented by one unit at the input's own resolution,
carrying via the calendar module. Masked values widen to the
coarsest un-masked prefix and use the internal mask-bounds
helper to determine the enclosing span.

# `overlap_certainty`

```elixir
@spec overlap_certainty(interval_like(), interval_like()) ::
  certainty() | {:error, term()}
```

The certainty that `a` and `b` intersect, given their `±` margins.

The three-valued counterpart of `overlaps?/2`. Each margin-bearing
endpoint is widened into a range, and the result reports whether
intersection holds for every consistent placement (`:certain`), some
(`:possible`), or none (`:impossible`). Crisp operands degrade exactly
to `overlaps?/2` (only `:certain`/`:impossible` occur).

### Arguments

* `a` and `b` are each a bounded `t:Tempo.t/0`, `t:Tempo.Interval.t/0`,
  or single-member `t:Tempo.IntervalSet.t/0`.

### Returns

* `:certain`, `:possible`, or `:impossible`.

* `{:error, reason}` when either operand is open-ended or a
  multi-member set.

### Examples

    iex> Tempo.Interval.overlap_certainty(~o"2000±1Y", ~o"2010±1Y")
    :impossible

    iex> Tempo.Interval.overlap_certainty(~o"2000±1Y", ~o"2001±1Y")
    :possible

    iex> Tempo.Interval.overlap_certainty(~o"2000Y", ~o"2000Y")
    :certain

# `possibly_after?`

```elixir
@spec possibly_after?(interval_like(), interval_like()) :: boolean()
```

`true` when `a` *could* start after `b` ends for some placement of
their `±` margins.

### Examples

    iex> Tempo.Interval.possibly_after?(~o"2001±1Y", ~o"2000±1Y")
    true

# `possibly_before?`

```elixir
@spec possibly_before?(interval_like(), interval_like()) :: boolean()
```

`true` when `a` *could* end before `b` starts for some placement of
their `±` margins.

### Examples

    iex> Tempo.Interval.possibly_before?(~o"2000±1Y", ~o"2001±1Y")
    true

# `possibly_overlaps?`

```elixir
@spec possibly_overlaps?(interval_like(), interval_like()) :: boolean()
```

`true` when `a` and `b` *could* intersect for some placement of their
`±` margins — `overlap_certainty/2` is `:certain` or `:possible`.

### Examples

    iex> Tempo.Interval.possibly_overlaps?(~o"2000±1Y", ~o"2001±1Y")
    true

    iex> Tempo.Interval.possibly_overlaps?(~o"2000±1Y", ~o"2010±1Y")
    false

# `possibly_within?`

```elixir
@spec possibly_within?(interval_like(), interval_like()) :: boolean()
```

`true` when `a` *could* fall within `b` for some placement of their
`±` margins.

### Examples

    iex> Tempo.Interval.possibly_within?(~o"2000±1Y", ~o"2000Y")
    true

# `relation`

```elixir
@spec relation(interval_like(), interval_like()) :: relation() | {:error, term()}
```

Classify the Allen relation between two interval-like values.

Returns one of 13 mutually exclusive relations from Allen's
interval algebra — a richer answer than stdlib's ternary
`compare/2` (`:lt` / `:eq` / `:gt`), which collapses intervals
to their start points and loses the containment and overlap
distinctions that interval algebra captures. Hence the name
`relation` rather than `compare`.

For intervals `X = [x₁, x₂)` and `Y = [y₁, y₂)` under Tempo's
half-open convention:

| Relation          | Shape (X relative to Y)          | Condition                      |
| ----------------- | -------------------------------- | ------------------------------ |
| `:precedes`       | X ends strictly before Y starts  | `x₂ < y₁`                      |
| `:meets`          | X ends exactly at Y's start      | `x₂ = y₁`                      |
| `:overlaps`       | X starts before Y, ends inside   | `x₁ < y₁ < x₂ < y₂`            |
| `:finished_by`    | X contains Y, shared end         | `x₁ < y₁ ∧ x₂ = y₂`            |
| `:contains`       | X strictly contains Y            | `x₁ < y₁ ∧ x₂ > y₂`            |
| `:starts`         | Shared start, X ends earlier     | `x₁ = y₁ ∧ x₂ < y₂`            |
| `:equals`         | Identical endpoints              | `x₁ = y₁ ∧ x₂ = y₂`            |
| `:started_by`     | Shared start, X ends later       | `x₁ = y₁ ∧ x₂ > y₂`            |
| `:during`         | X strictly inside Y              | `x₁ > y₁ ∧ x₂ < y₂`            |
| `:finishes`       | X starts after Y, shared end     | `x₁ > y₁ ∧ x₂ = y₂`            |
| `:overlapped_by`  | Y starts before X, ends inside X | `y₁ < x₁ < y₂ < x₂`            |
| `:met_by`         | X starts exactly at Y's end      | `x₁ = y₂`                      |
| `:preceded_by`    | X starts strictly after Y's end  | `x₁ > y₂`                      |

Every pair of non-empty bounded intervals stands in exactly
one of these relations.

### Arguments

* `a` and `b` are each one of:

  * a `t:Tempo.t/0` point (materialised via its implicit span).

  * a `t:Tempo.Interval.t/0`.

  * a `t:Tempo.IntervalSet.t/0` with exactly one member.

### Returns

* One of the 13 relation atoms.

* `{:error, reason}` when either operand is a multi-member
  IntervalSet, an open-ended interval, or otherwise can't be
  reduced to a single bounded interval. For multi-member
  sets use `Tempo.IntervalSet.relation_matrix/2`.

### Examples

    iex> a = Tempo.Interval.new!(from: ~o"2026-06-01", to: ~o"2026-06-10")
    iex> b = Tempo.Interval.new!(from: ~o"2026-06-05", to: ~o"2026-06-15")
    iex> Tempo.Interval.relation(a, b)
    :overlaps

    iex> Tempo.Interval.relation(~o"2026Y", ~o"2026-06-15")
    :contains

# `relation_certainty`

```elixir
@spec relation_certainty(interval_like(), interval_like(), relation() | [relation()]) ::
  certainty() | {:error, term()}
```

The certainty that `relation(a, b)` is (one of) `target`.

Certainty is containment of the possible relations in `target`. The
possible relations account for any `±` margin and for underspecification:
an unspecified-digit operand (`~o"20XXY"`) is read over every grounding
its mask admits, and two un-anchored operands compare on a shared leading
unit.

### Arguments

* `a` and `b` are bounded interval-like values.

* `target` is a single Allen relation atom (e.g. `:during`) or a list
  of relation atoms.

### Returns

* `:certain`, `:possible`, or `:impossible`.

* `{:error, reason}` for open-ended or multi-member operands, or a
  `t:Tempo.RequiresAnchorError.t/0` when an un-anchored operand is
  compared across resolution axes.

### Examples

    iex> Tempo.Interval.relation_certainty(~o"2000±1Y", ~o"2010±1Y", :precedes)
    :certain

    iex> Tempo.Interval.relation_certainty(~o"2000Y", ~o"2000Y", :equals)
    :certain

    iex> # some year in 2000–2099 may precede 2050, may follow it
    iex> Tempo.Interval.relation_certainty(~o"20XXY", ~o"2050Y", :precedes)
    :possible

# `resolution`

```elixir
@spec resolution(t()) :: Tempo.time_unit() | :undefined
```

Return the interval's span resolution — the coarsest unit at
which `from` and `to` differ.

Under the half-open `[from, to)` convention, this is the unit
that "ticks forward" across the span. `[2026-06-15, 2026-06-16)`
ticks at the day; `[2026-06-01, 2026-07-01)` ticks at the month;
`[2026, 2027)` ticks at the year.

Unlike `Tempo.resolution/1` on a filled endpoint (which would
report the finest unit present on the time keyword list after
`Tempo.to_interval/1` has padded missing units with their
minimums), this function reports the **span's** resolution —
the authoritative scale of the interval itself.

### Arguments

* `interval` is a `t:t/0`. Must be bounded (both endpoints
  present) — `:undefined` endpoints return `:undefined`.

### Returns

* A unit atom (`:year`, `:month`, `:day`, `:hour`, `:minute`,
  `:second`, …), or `:undefined` for open-ended intervals.

### Examples

    iex> iv = %Tempo.Interval{from: ~o"2026-06-15", to: ~o"2026-06-16"}
    iex> Tempo.Interval.resolution(iv)
    :day

    iex> iv = %Tempo.Interval{from: ~o"2026-06", to: ~o"2026-07"}
    iex> Tempo.Interval.resolution(iv)
    :month

# `shorter_than?`

```elixir
@spec shorter_than?(t(), Tempo.Duration.t()) :: boolean()
```

`true` when the interval's length is strictly less than the
given duration.

### Examples

    iex> iv = %Tempo.Interval{from: ~o"2026-06-15T09", to: ~o"2026-06-15T10"}
    iex> Tempo.Interval.shorter_than?(iv, ~o"PT2H")
    true
    iex> Tempo.Interval.shorter_than?(iv, ~o"PT1H")
    false

# `spans_leap_second?`

```elixir
@spec spans_leap_second?(t()) :: boolean()
```

Return `true` when the interval `[from, to)` contains at least
one IERS-announced positive leap second.

A historical predicate: it doesn't affect any other Tempo
operation. Use it when you want to know if an elapsed-time
calculation needs leap-second correction, or to flag intervals
for a scientific/astronomy pipeline.

### Arguments

* `interval` is a `t:t/0` with both endpoints present. Unbounded
  intervals always return `false` (open-ended to `:undefined`)
  and pre-Unix-era intervals pre-1972 return `false` (IERS leap
  seconds started in 1972).

### Returns

* `true` when at least one entry from `Tempo.LeapSeconds.dates/0`
  falls inside `[from, to)`.

* `false` otherwise.

### Examples

    iex> iv = %Tempo.Interval{from: ~o"2016-12-31T23:00:00Z", to: ~o"2017-01-01T01:00:00Z"}
    iex> Tempo.Interval.spans_leap_second?(iv)
    true

    iex> iv = %Tempo.Interval{from: ~o"2026-06-15", to: ~o"2026-06-16"}
    iex> Tempo.Interval.spans_leap_second?(iv)
    false

# `to`

```elixir
@spec to(t()) :: Tempo.t() | :undefined
```

Return the interval's `to` endpoint.

Under half-open `[from, to)` semantics, this is the exclusive
upper bound — the first instant **outside** the span.

### Arguments

* `interval` is a `t:t/0`.

### Returns

* The `to` endpoint as a `t:Tempo.t/0` or `:undefined` for
  open-ended intervals.

### Examples

    iex> iv = %Tempo.Interval{from: ~o"2026-06-15", to: ~o"2026-06-20"}
    iex> Tempo.Interval.to(iv) |> Tempo.day()
    20

# `within?`

```elixir
@spec within?(interval_like(), interval_like()) :: boolean()
```

`true` when `a` lies inside `b` inclusive of shared
endpoints (Allen's `:equals | :starts | :during | :finishes`).
The canonical "does this fit inside that window?" predicate.

### Examples

    iex> a = %Tempo.Interval{from: ~o"2026-06-15T10", to: ~o"2026-06-15T11"}
    iex> window = %Tempo.Interval{from: ~o"2026-06-15T09", to: ~o"2026-06-15T17"}
    iex> Tempo.Interval.within?(a, window)
    true
    iex> # Candidate shares the window's start — still inside
    iex> a2 = %Tempo.Interval{from: ~o"2026-06-15T09", to: ~o"2026-06-15T10"}
    iex> Tempo.Interval.within?(a2, window)
    true

# `within_certainty`

```elixir
@spec within_certainty(interval_like(), interval_like()) ::
  certainty() | {:error, term()}
```

The certainty that `a` falls within `b`, given any uncertainty in either.

The three-valued counterpart of `within?/2` (Allen `:equals | :starts
| :during | :finishes`). Uncertainty may be a `±` margin *or*
underspecification — an unspecified-digit value (`~o"20XXY"`) is read
over the set of years its mask admits, so `:possible` reports that some
but not all groundings fall within `b`. Crisp operands degrade exactly to
`within?/2`.

### Arguments

* `a` and `b` are each a bounded `t:Tempo.t/0`, `t:Tempo.Interval.t/0`,
  or single-member `t:Tempo.IntervalSet.t/0`.

### Returns

* `:certain`, `:possible`, or `:impossible`.

* `{:error, reason}` for open-ended or multi-member operands, or a
  `t:Tempo.RequiresAnchorError.t/0` when an un-anchored operand is
  compared across resolution axes.

### Examples

    iex> Tempo.Interval.within_certainty(~o"2000Y6M", ~o"2000Y")
    :certain

    iex> Tempo.Interval.within_certainty(~o"2000±1Y", ~o"2000Y")
    :possible

    iex> # ~o"20XXY" is some year in [2000, 2100); 2000 escapes [2001, 2101)
    iex> Tempo.Interval.within_certainty(~o"20XXY", ~o"2001Y/2101Y")
    :possible

---

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