Enumeration semantics

Copy Markdown View Source

Tempo implements the Enumerable protocol for %Tempo{}, %Tempo.Set{}, and %Tempo.Interval{}. This document explains what each value can and cannot be iterated over, and why.

Setup — required for every example

Every code example in this guide uses the ~o sigil from Tempo.Sigils. Before running any of them — in iex, a script, or a module — you must bring the sigil into scope:

import Tempo.Sigils

The import adds only sigil_o/2 and sigil_TEMPO/2 to the caller's namespace; no helper functions leak in.

1. The two kinds of iteration

Tempo values are bounded intervals on the time line, not instants. That informs two distinct iteration modes, each produced by a different shape of value:

  • Implicit enumeration — "drill into this span." A single %Tempo{} at some resolution yields its sub-units. Enum.take(~o"2022Y", 3) yields [2022Y1M, 2022Y2M, 2022Y3M] — the year span is walked one month at a time. Implicit enumeration is the default when the value is a single resolved point at a coarser-than-finest resolution.

  • Forward-stepping — "walk across this interval." A %Tempo.Interval{} yields each resolution-unit along the span. Enum.take(Tempo.Interval.new!(from: ~o"1985Y", to: :undefined), 3) yields [1985Y, 1986Y, 1987Y] — successive years at the endpoint's own resolution.

Iteration always honours the half-open [from, to) convention: the lower bound is inclusive, the upper bound is exclusive. This makes adjacent intervals concatenate cleanly without overlap or gap.

Tempo.to_interval/1 converts between the two forms: it takes any implicit-span %Tempo{} and returns the equivalent %Tempo.Interval{} with concrete from and to endpoints. Iteration on the explicit form is guaranteed to yield the same sequence as iteration on the implicit source (for every shape where both are defined — see §5.6 for the edge cases). to_interval/1 is idempotent on values that are already intervals.

2. Enumerable — what you can iterate

2.1. Single %Tempo{} values

Every resolved Tempo at coarser-than-finest resolution is enumerable via implicit enumeration. The iteration unit is the next-finer unit that isn't already specified.

ConstructExampleYields
Year2022Y12 months
Year-month2022-06days of June
Year-month-day2022-06-1524 hours
Hour2022-06-15T1060 minutes
Minute2022-06-15T10:3060 seconds
Week2022-W247 days
Ordinal date2022-16624 hours

2.2. Explicit ranges and sets

Any component may carry a range, a range with step, a set of values, or a cartesian product of the above.

ConstructExampleIterates over
Inclusive range{1..3}Mmonths 1, 2, 3
Stepped range{1..-1//2}Wevery second week of the year
All-of set{2021,2022}Y2021, then 2022
One-of set[1984,1986,1988]exactly those three years
Cartesian product2022Y{1..2}M{1..2}DJan 1, Jan 2, Feb 1, Feb 2

2.3. Missing / unknown digits (EDTF masks)

A digit marked X means "any value in this position." Tempo expands the mask to the range of candidate values and iterates it — the value is just as enumerable as an explicit range written with the same bounds.

ConstructExampleExpanded range
Last digit unknown (year)156X1560..1569
Positive century masked1XXX1000..1999
Negative century masked-1XXX-1999..-1000 (most-negative first)
Fully unspecified yearXXXX1000..9999
Month-day masked1985-XX-XXyear fixed, month/day iterate
Month only masked1985-XX-15year and day fixed, month iterates

2.4. EDTF long-year shapes

ConstructExampleNotes
Y-prefix short yearY2022same as 2022; 12 months
Y-prefix long yearY12345single anchored year; 12 months
Exponent long yearY17E81 700 000 000; single anchored year
Significant-digits year1950S2block 1900..1999; 100 × 12 months
Significant-digits longY171010000S8block of 10 candidates

Significant-digits blocks are capped at 10 000 candidates. Larger blocks (e.g. Y171010000S3, which would be 10⁶ candidates) raise a clear ArgumentError — the parsed value is still usable as a data value, you just cannot iterate it.

2.5. Groups and selections

ConstructExampleBehaviour
Group2022Y5G2MU"5th group of 2 months" = months 9–10; then iterates days
Selection2022YL1MN"the 1st month of 2022" — selection tuple preserved on every yielded value

2.6. Qualifications (EDTF Level 1 and Level 2)

Qualifications describe epistemic state (? uncertain, ~ approximate, % both) and never affect whether a value is enumerable. They propagate verbatim to every yielded value.

ConstructExampleEach yielded value carries
Expression-level2022Y?qualification: :uncertain
Leading?2022-06-15qualification: :uncertain
Approximate~2022qualification: :approximate
Component-level2022-?06-15qualifications: %{month: :uncertain}
Mixed components2022?-?06-%15per-component map

2.7. IXDTF metadata

Time zone, calendar, and tagged suffixes attach to the :extended field and flow through enumeration unchanged.

ConstructExample
Zone only2022-06-15T10:30[Europe/Paris]
Calendar only2022-06-15T10:30[u-ca=hebrew]
Zone + offset + calendar2022-06-15T10:30[+05:30][u-ca=hebrew]
Per-endpoint on interval10:00[Europe/Paris]/12:00[Europe/London]

The endpoint that anchors iteration (from) provides the metadata carried on each yielded value.

2.8. Intervals — closed and forward-open

ShapeExampleIteration
Closed day1985-01-01/1985-01-04Jan 1, 2, 3 (half-open)
Closed month1985-12/1986-02Dec 1985, Jan 1986
Closed week2022-W05/2022-W08W5, W6, W7
Mismatched resolutions1985/1986-061985, 1986 (both start before Jun 1 1986)
Open upper1985/..1985, 1986, 1987, … (use Enum.take/2)
Open upper, hour1985-01-01T10/..10:00, 11:00, 12:00, …
Per-endpoint qualifier1984?/2004~1984 through 2003, each carrying its endpoint's qualifier where applicable

Mismatched-resolution endpoints are compared as their concrete start-moments: missing trailing units fill with their unit minimum (:month / :day / :week from 1, everything else from 0).

2.9. Implicit-to-explicit conversion (Tempo.to_interval/1)

Every enumerable %Tempo{} has an explicit equivalent — either a single %Tempo.Interval{} (contiguous span) or a %Tempo.IntervalSet{} (sorted, member-preserving list of intervals). Tempo.to_interval/1 materialises the appropriate form under the half-open [from, to) convention. The conversion preserves every piece of source metadata (:qualification, :qualifications, :extended, :shift, :calendar) on both endpoints.

Call Tempo.to_interval_set/1 if you always want the IntervalSet form (a single interval is wrapped in a one-element set).

Inputfrom.timeto.time
2026[year: 2026, month: 1][year: 2027, month: 1]
2026-01[year: 2026, month: 1, day: 1][year: 2026, month: 2, day: 1]
2026-01-15[year: 2026, month: 1, day: 15, hour: 0][year: 2026, month: 1, day: 16, hour: 0]
2026-01-15T10[…, hour: 10, minute: 0][…, hour: 11, minute: 0]
156X[year: 1560][year: 1570]
-1XXX[year: -1999][year: -999]
1985-XX-XX[year: 1985][year: 1986]
1985-06-XX[year: 1985, month: 6][year: 1985, month: 7]

Mask rules:

  • A year mask (156X, -1XXX) translates directly to a year range via Tempo.Mask.mask_bounds/1. The signed half-open upper bound is computed as -magnitude_min + 1 for negative masks.

  • A finer-unit mask (1985-XX-XX, 1985-06-XX, 1985-XX-15) widens to the coarsest un-masked prefix and increments there. 1985-XX-XX becomes year-resolution bounds because the mask at month-level can't map cleanly to a valid-month range; 1985-06-XX keeps month resolution because only the day is masked.

  • 1985-XX-15 (day specified, month masked) is semantically non-contiguous — the covered moments are "the 15th of any 1985 month" which isn't a single interval. to_interval/1 accepts the looser bound ([year: 1985]..[year: 1986]) rather than returning a set.

to_interval/1 is idempotent on existing intervals and interval sets. Multi-valued AST shapes (ranges, stepped ranges, iterated groups, all-of sets) materialise to %Tempo.IntervalSet{} with each expanded member distinct. One-of sets ([a,b,c]) are epistemic (the value is one of these, we don't know which) and return an error from to_interval/1 — flattening them would assert all members happened, which is semantically wrong. Bare %Tempo.Duration{} values also return an error (no anchor on the time line).

Input shapeResult
Scalar ~o"2022Y"%Tempo.Interval{}
Contiguous range ~o"2022Y{1..3}M"%Tempo.IntervalSet{} with 3 members (one per month)
Stepped range ~o"2022Y{1..-1//3}M"%Tempo.IntervalSet{} with N disjoint members
All-of set ~o"{2020,2021,2022}Y"%Tempo.IntervalSet{} with 3 members (one per year)
One-of set ~o"[2020Y,2021Y,2022Y]"{:error, "... epistemic disjunction ..."}
Bare Duration ~o"P3M"{:error, "... no anchor ..."}

For the canonical instant-set form (touching members merged into one span), pipe the result through Tempo.IntervalSet.coalesce/1.

2.10. %Tempo.IntervalSet{} — multi-interval values

%Tempo.IntervalSet{intervals: [%Tempo.Interval{}, ...]} holds a sorted list of member intervals. By default the constructor preserves member identity — each interval stays a distinct member with its own metadata. Tempo.IntervalSet.new/1 sorts by from endpoint; it does NOT coalesce adjacent or overlapping intervals unless called as new(intervals, coalesce: true) or passed through Tempo.IntervalSet.coalesce/1.

iex> {:ok, tempo} = Tempo.from_iso8601("2022Y{1..-1//3}M")
iex> {:ok, set} = Tempo.to_interval(tempo)
iex> Tempo.IntervalSet.count(set)
4

Enumeration walks each interval in time order, crossing interval boundaries seamlessly: Enum.to_list(set) on four month-sized intervals yields every day in each month, one interval at a time.

IntervalSet is the form used by set operations — Tempo.union/2, Tempo.intersection/2, Tempo.complement/2, Tempo.difference/2, and predicates. See guides/set-operations.md for the full treatment. Any call that needs a uniform-shape input can use Tempo.to_interval_set/1.

2.10. Seasons

The parser expands season codes into intervals before enumeration sees them.

CodeExampleExpands to
Astronomical (25–32)2022-25March equinox to June solstice (computed via Astro)
Meteorological (21–24)2022-21March 1 to May 31 (calendar approximation)

3. Not enumerable by design

These constructs cannot be enumerated, and no amount of future implementation will change that. They raise ArgumentError with a clear message, or the protocol falls back to {:error, Enumerable.<Module>} for calls like Enum.count/1.

3.1. Bare %Tempo.Duration{} values

A duration is a length, not a sequence. P3M means "three months" with no anchor on the time line. Iterating it would be nonsensical — three months starting when?

ConstructExample
Pure durationP3M, P1Y2M3D, PT30M

A duration that participates in an interval (1985-01/P3M) is not a bare duration — see §4.1 for that case.

No Enumerable instance is defined for Tempo.Duration. Calls like Enum.take(~o"P3M", 3) raise Protocol.UndefinedError.

3.2. Fully open intervals

../.. has no anchor at all. There is nowhere to start and nowhere to stop.

iex> {:ok, interval} = Tempo.from_iso8601("../..")
iex> Enum.take(interval, 3)
** (ArgumentError) Cannot enumerate a fully open interval `../..` — no anchor from which to start iteration.

3.3. Open-lower intervals

../1985 has an upper anchor but no lower anchor. Enumerable iterates forward by protocol convention, which requires a lower bound. Iterating backwards from the upper bound would be surprising and would invert the half-open semantics.

iex> {:ok, interval} = Tempo.from_iso8601("../1985-12-31")
iex> Enum.take(interval, 3)
** (ArgumentError) Cannot enumerate an interval with an open lower bound `../to` — Enumerable iterates forward from the lower bound, which is not defined.

3.4. Values at the finest available resolution

A fully-specified second-resolution datetime has no finer unit to drill into. Tempo deliberately does not invent a sub-second "tick" unit — the value is a single indivisible moment at its declared resolution.

iex> {:ok, value} = Tempo.from_iso8601("2022-06-15T10:30:00Z")
iex> Enum.take(value, 1)
** (ArgumentError) Cannot enumerate a Tempo at :second resolution — no finer unit is defined. …

3.5. Significant-digits blocks larger than 10 000

Y171010000S3 would expand to 171010000..171019999 — a million candidate years. Tempo refuses to iterate a block that large rather than hang or consume unbounded memory.

iex> {:ok, value} = Tempo.from_iso8601("Y171010000S3")
iex> Enum.take(value, 3)
** (ArgumentError) Cannot enumerate a significant-digits block of 1000000 candidates (limit: 10000). …

The parsed value itself is usable for comparison, equality, and round-trip serialisation; only iteration is refused.

4. Not enumerable — not yet implemented

These will be enumerable in future versions, but are not today. Each is pinned by a test that will force a conscious update when the implementation lands.

4.1. count/1, member?/2, slice/1 on Tempo.Interval

All three currently return {:error, Enumerable.Tempo.Interval}, which tells Elixir's Enum module to fall back to iterating via reduce/3.

Precise implementations need:

  • For count/1 on closed intervals — Tempo-to-Tempo distance in resolution-units (calendar-aware).

  • For member?/2 — a full Tempo comparison (lib/comparison.ex is currently a template).

  • For slice/1 — addressing the Nth element directly.

Tracked with the set-operations milestone, which also depends on Tempo.relation/2.

4.2. count/1 and member?/2 on %Tempo{} and %Tempo.Set{}

Both return {:error, Enumerable.Tempo} / {:error, Enumerable.Tempo.Set} today. Will be filled in alongside the same comparison primitives as §4.2.

5. Semantic edge cases

5.1. "Missing" versus "unknown" versus "qualified"

Three similar-sounding situations have distinct enumeration meanings:

  • Missing (not specified). 2022Y simply omits finer units. The value is the interval of all of 2022 (§2.1) and implicit enumeration walks its months. Fully enumerable.

  • Unknown digit (X mask). 156X declares "this position is any valid digit." The mask expands to a range of candidate values (§2.3). Fully enumerable.

  • Qualified (?, ~, %). 2022Y? is a concrete, fully-specified value — the year 2022 — annotated with uncertainty about the source. The qualification attaches to metadata; it does not change what is iterated (§2.6). Fully enumerable.

These three are semantically distinct and should not be conflated:

DescriptionSyntaxWhat's iterated
"Some year in the 1560s"156Xeach year 1560..1569
"All of the year 1560"1560each month of 1560
"The year 1560, uncertainly"1560?each month of 1560, every yielded value flagged uncertain

5.2. Qualification propagation on intervals

Per-endpoint qualifiers attach to that endpoint's %Tempo{} struct, not to the interior values.

iex> {:ok, interval} = Tempo.from_iso8601("1984?/2004~")
iex> interval.from.qualification
:uncertain
iex> interval.to.qualification
:approximate

When the interval is enumerated forward from :from, each yielded value inherits :from's qualification. The :to endpoint's qualifier is a property of the boundary, not the interior.

5.3. IXDTF metadata propagation on intervals

Per-endpoint IXDTF suffixes ([Europe/Paris]) attach to that endpoint. A top-level IXDTF suffix on an interval propagates to each endpoint that does not already carry its own. Iteration walks forward from :from, so yielded values carry :from's zone, offset, and calendar.

5.4. Calendar-aware increment

Forward-stepping through an interval uses calendar.months_in_year/1, calendar.days_in_month/2, calendar.weeks_in_year/1, and calendar.days_in_week/0 for carry. Iterating an interval whose endpoint's calendar is Hebrew, Islamic, or any other supported calendar Just Works — the carry boundaries change to match.

5.5. DST transitions

Zone-aware iteration currently treats enumeration as operating on wall-clock time and passes the zone_id through unchanged on each yielded value. DST transitions are not compensated. Iterating hours across a DST boundary yields each wall-clock hour in turn, which may skip or repeat an instant-clock hour. This is a deliberate simplification and is documented so callers can choose to correct for it downstream.

5.6. Parity between implicit and explicit iteration

For every %Tempo{} where both implicit and explicit iteration are defined, the two produce identical sequences:

iex> {:ok, tempo} = Tempo.from_iso8601("2026-01")
iex> implicit = Enum.to_list(tempo)
iex> {:ok, interval} = Tempo.to_interval(tempo)
iex> explicit = Enum.to_list(interval)
iex> implicit == explicit
true

Known divergences:

  • Second-resolution values. ~o"2026-01-15T10:30:00" has no finer unit to drill into. Implicit iteration raises ArgumentError; to_interval/1 raises a matching error. Neither form yields a value.

  • Masked values iterated implicitly. The current implicit enumeration of masked values (1985-XX-XX) has known quirks — it does not always walk the full cartesian product of valid month/day pairs. to_interval/1 widens to the coarsest un-masked prefix and produces a clean span; iterating that interval yields the straightforward forward-stepped sequence. Prefer the explicit form for set operations on masked values.

6. Summary table

CategoryExamples
Enumerableevery standard ISO 8601 / EDTF value with a concrete anchor — single values, ranges, sets, masks, long years, qualified values, IXDTF-tagged values, closed intervals, open-upper intervals, seasons, mixed-resolution intervals
Not enumerable by designbare %Tempo.Duration{}, fully open intervals ../.., open-lower intervals ../to, values at finest resolution, significant-digits blocks > 10 000 candidates
Not enumerable (deferred)exact count/1 / member?/2 / slice/1 on intervals, count/1 / member?/2 on %Tempo{} and %Tempo.Set{}