⏱ IntervalClock

the protocol, v1

The IntervalClock Protocol, v1

A canonical, globally computable name for every interval and every periodic phase class on a shared timeline — an H3 for time.

H3 gives every hexagon on Earth a canonical hierarchical ID at every resolution. This protocol does the same for time: calendar units (hours, days, ISO weeks), every possible cron schedule, arbitrary rational periods ("every 1/3 second", any Hz), and pure periodic signal components — each gets exactly one name, computable by anyone, with no registry and no clock.

Names are pure functions of their parameters. Two implementations on opposite sides of the planet compute identical names for identical inputs; NTP-style synchronization is needed only to answer "which state are we in right now."

This document is the normative spec. The reference implementation is the intervalclock Python package in this repository; the browser core in web/ts-core implements the same encodings byte-for-byte.


1. The timeline

2. Objects and canonical forms

Every nameable set of time is exactly one of the first nine rows below. The last three name a step rather than a set — how long, not when (§2.5) — and CalSpan is the one that crosses back:

Type Meaning
NEVER the empty set
ALWAYS all of time (the root of every hierarchy)
Instant(t) one rational point
Span(a, b) one half-open interval [a, b), a < b
Φ(w, m, φ) the atom: one pulse of width w per period P = w·m, forever
PSet(P, arcs) closure under boolean ops: disjoint arcs on the circle ℝ/P
Cell a symbolic calendar name (§5)
Cron a canonical cron disjunction (§6)
Windowed(S, C) support × class: a phase class restricted to a span (§2.4)
Duration(d) a measure, not a set: an exact step of d ∈ ℚ seconds (§2.5)
CalDuration a nominal step: months, days, exact tail — a length only once anchored (§2.5)
CalSpan a nominal step anchored to an instant: a symbolic interval (§2.5)

2.1 The atom Φ(w, m, φ)

Φ(w, m, φ) = ⋃ₙ∈ℤ [φ + nP, φ + nP + w), with w ∈ ℚ>0, m ∈ ℤ≥2, P = w·m, φ ∈ [0, P), all rationals in lowest terms.

User-facing constructions may use (w, m, state k, offset δ). Two identities make the canonical reduction:

  1. k absorbs into φ: L(w, m, k, δ) = L(w, m, 0, δ + k·w).
  2. φ wraps: offsets are meaningful only mod P.

So φ = (δ + k·w) mod P, and offsets that exceed the period are a non-issue by construction. "State 2 of 3" is display sugar, recoverable as k = φ/w exactly when φ lies on the w-grid.

Uniqueness: for m ≥ 2 the point-set determines the parameters — w is the length of any maximal component interval, P the spacing of consecutive component starts, φ the unique start in [0, P). Distinct canonical triples ⇒ distinct sets ⇒ one name per class.

Mandatory normalizations: m = 1 (or any construction whose pulse covers the whole period) → ALWAYS. Empty constructions → NEVER.

Partition property (the H3-ness): the m siblings Φ(w, m, k·w) for k = 0…m−1 partition all of time. At w = 1/3 s, m = 3 these are the three states of a 1/3-second clock — modulo math, with φ carrying the phase.

2.2 The closure PSet(P, arcs)

Phase classes are not closed under ∪/∩/¬. The closure is a period P ∈ ℚ>0 plus disjoint, non-adjacent arcs (start ∈ [0, P), width ∈ (0, P]) on the circle ℝ/P, sorted by start (an arc may wrap through 0; adjacent arcs are merged).

Canonical reduction (in order): 1. Merge/normalize arcs, including wraparound merging. 2. Minimize the period: replace P with the smallest P′ | P under which the arc pattern is shift-invariant. 3. Full circle → ALWAYS; no arcs → NEVER; a single arc with P′/w ∈ ℤ≥2 → the Φ it equals. A single-arc PSet with non-integral P′/w is legal and canonical as PSet.

Boolean algebra: to combine periods P₁, P₂, lift both to L = lcm(P₁, P₂) using rational lcm lcm(a/b, c/d) = lcm(a,c)/gcd(b,d), unroll arcs, operate on circular interval sets, re-reduce. Exact and terminating. Cost warning: L blows up for unrelated periods — implementations should expose a complexity measure.

2.3 Containment: the divisibility lattice

Exact subset predicate for A = Φ(w₁,m₁,φ₁), B = Φ(w₂,m₂,φ₂): let g = gcd(P₁, P₂) (rational gcd gcd(a/b, c/d) = gcd(ad, cb)/bd); the starts of A's pulses taken mod P₂ form the finite orbit O = { (φ₁ + j·g) mod P₂ : j = 0 … P₂/g − 1 }. Then

A ⊆ B ⟺ w₁ ≤ w₂ and ∀ r ∈ O: (r − φ₂) mod P₂ ≤ w₂ − w₁.

When P₂ | P₁ the orbit is one point and this is the familiar "divisibility + compatible offset" rule; the general form also decides non-divisible cases exactly. General PSet containment: A∩¬B = ∅.

Hierarchy axes (both partition the parent — children tile the parent): - decimate (period × c): children Φ(w, mc, φ + iP), i = 0…c−1 — "every c-th occurrence". - duty (slot ÷ c): children Φ(w/c, mc, φ + i·w/c) — "the c slices of each pulse".

harmonic n of Φ (defined when n | m) is Φ(w, m/n, φ mod P/n) — same pulse width, n× frequency, and an ancestor (superset). Subharmonics are the decimation children.

Time has no privileged branching factor (7-day weeks vs decimal vs dyadic): the lattice is the invariant; hierarchies are named chains through it. Implementations ship the decimal grid (w = 10^k s, factor-10 subdivision) and the calendar lens; other grids are user-definable.

2.4 Windowed classes: support × phase

A time range + an interval describes a pure sinusoid perfectly.

Windowed(S, C) = S ∩ C for a Span S and periodic class C: C fixes the period and phase alignment (eternal); S fixes the support. Amplitude is measurement data, not identity — it never enters a name. A pure tone is one windowed class plus an amplitude scalar; a sampled signal decomposes into windowed classes sharing one support (the capture window) with per-class amplitudes; "extend the signal forever" = drop the span.

Containment is componentwise: (S₁,C₁) ⊆ (S₂,C₂) ⟺ S₁⊆S₂ ∧ C₁⊆C₂; an eternal class is the degenerate unbounded-support case. A windowed class no pulse of which intersects its support reduces to NEVER.

2.5 Durations: the canonical step

Everything above is a set of time. A duration is not: it is a measure — a length with no place on the timeline. Formally it is the translation-invariance class of a Span (Span(a, b) and Span(a+t, b+t) have the same duration): the timeline quotiented by the translation group. It splits in two along exactly the line §5 draws:

Duration(d) — d ∈ ℚ seconds of TAI, signed, zero permitted. Exact, table-free, eternal.

CalDuration(months, days, secs) — months, days ∈ ℤ, secs ∈ ℚ, a nominal step applied in that order. Not a length at all until it is anchored: months vary, DST days are 23/25 h, leap-second days are 86 401 s.

Three fields suffice because the civil calendar fixes everything else exactly: 1 year = 12 months and 1 week = 7 days always, so years and weeks fold into months and days, while hours and minutes are exact physical seconds and fold into secs. What is left genuinely nominal is "same day-of-month next month" and "same wall clock tomorrow".

Mandatory normalizations: years → months (×12), weeks → days (×7), hours/minutes → secs; and a nominal step with months = days = 0 is a Duration and MUST be spelled as one — a civil second is an SI second by definition, while every larger unit can vary. Day-of-month clamping is the standard rule: 31 Jan + 1 month = 28 Feb (29 in a leap year). The secs tail is added as exact physical time, so a tail crossing an inserted leap second lands a second earlier on the wall clock than a naive reading suggests — the honest answer, and the reason h/m/s are not civil units.

Anchoring turns a step back into a set of time:

Grids. A repeated step is the coordinate system that tiles all of time: slot n of grid(d, φ) is [φ + nd, φ + (n+1)d), and its m siblings Φ(d, m, φ + k·d) partition it exactly as §2.1 requires. A grid is a coordinate system, not a set of time — as a set it is ALWAYS — so it gets no ID; its identity is the pair (Duration, phase), both of which are named. The decimal grid of §2.3 is one such grid; the calendar is its civil counterpart.

Translation. Durations act on sets of time, and that action is what a duration is: X + d moves every point of X by d. Instant + d is an instant, Span + d a span, and Φ(w, m, φ) + d = Φ(w, m, (φ+d) mod P) — a class is invariant under a shift by its own period, which is exactly why φ lives in [0, P), and shifting by k·w walks the m siblings of §2.1. Symbolic names do not translate: a Cell, a Cron, or a nominal step is a civil name, not a point set, and a nominal step has no fixed length to translate by — resolve at an anchor first.

Durations order, add and scale as the rationals they are: Instant − Instant is a Duration and Duration / Duration is a dimensionless ratio. A Duration is not a phase class — Φ also needs a modulus — but Φ's pulse width w and period P are both durations, which is where the two layers meet.

3. Canonical text grammar

The structured text name is the authoritative, registry-free canonical form. Pretty forms (Φ[w=1/3 s · m=3 · φ=2/3 s] (state 2 of 3)) are display-only.

id      := "ic1:" body
body    := "never" | "always"
         | "t:" rat                                  (* instant, TAI s since epoch *)
         | "s:" rat ";" rat                          (* span start;end *)
         | "c:" "w=" rat ";m=" posint ";phi=" rat    (* phase class *)
         | "u:" "P=" rat (";a=" rat "+" rat)+        (* pset: arcs start+width *)
         | "g:" cell ["!" zone]                      (* calendar cell, default UTC *)
         | "k:" cronrec ("+" cronrec)* ["!" zone]    (* cron; "+" joins records *)
         | "x:" "s:" rat ";" rat "|" clsbody         (* windowed: span | class *)
         | "d:" rat                                  (* duration: exact ℚ seconds *)
         | "n:" nomstep ["@" rat ["!" zone]]         (* nominal step, optionally anchored *)
         | "f:" fft                                  (* FFT provenance record, §9 *)
clsbody := ("c:" | "u:") …                           (* as above, without "ic1:" *)
nomstep := nomfld (";" nomfld)*                     (* fields in the order below *)
nomfld  := "mo=" int | "d=" int | "s=" rat          (* months, days, exact tail *)
fft     := "fs=" rat ";N=" posint ";k=" uint ";t0=" rat
           ";A=" hex16 ";th=" rat ";q=" rat          (* field order is canonical *)
hex16   := 16 lowercase hex digits                   (* IEEE-754 binary64, big-endian *)
rat     := ["-"] int ["/" posint]                    (* lowest terms, den > 0 *)
cell    := yyyy | yyyy"-"mm | yyyy"-"mm"-"dd
         | yyyy"-"mm"-"dd"T"hh [":"mm [":"ss]] | yyyy"-W"ww
cronrec := field "|" field "|" field "|" field "|" field   (* min|hr|dom|mon|dow *)
field   := "*" | term ("," term)*
term    := int | int"-"int | int"-"int"/"posint

Baked-in canonicalization: rationals in lowest terms with positive denominators; phi ∈ [0, P); zero-valued n: fields are omitted and the surviving ones keep the order mo, d, s (at least one must survive; an n: body with only s= is not canonical — it is a d:); no whitespace; cron month/dow names and 7-as-Sunday normalized to numbers at parse; cron field text is the deterministic greedy projection of the bitmask (§6).

For f: records specifically: fields appear in exactly the order above (one record, one spelling — parsers MUST reject reordered fields, unlike c: where a key-value reading is harmless); th is a phase in turns in [0, 1), never radians; k ∈ [0, ⌊N/2⌋]; k = 0 forces th = 0 and k = N/2 forces th ∈ {0, 1/2} (§9); A is the raw binary64 amplitude as 16 lowercase hex digits, and must be finite. Amplitude is spelled in hex because shortest-decimal float formatting differs between languages, which would break byte-identity; it is data, not identity (§2.4).

Worked examples (all verified by the reference implementation):

Thing Canonical name
The hour 14:00–15:00 UTC, 2026-08-01 ic1:g:2026-08-01T14 → resolves to ic1:s:1785592837;1785596437 (UTC−TAI = −37 s)
ISO week 31 of 2026 ic1:g:2026-W31 → ic1:s:1785110437;1785715237
"Every 1/3 s, state 2" (w=1/3, m=3, k=2, δ=0) ic1:c:w=1/3;m=3;phi=2/3
cron */5 * * * * (≡ 0-59/5 ≡ explicit list) ic1:k:0-55/5\|*\|*\|*\|*
A step of 2½ seconds ic1:d:5/2
One month, nominal (also spells "1 year" as mo=12) ic1:n:mo=1
One month starting 2022-09-11 UTC ic1:n:mo=1@1662854437 → resolves to ic1:s:1662854437;1665446437 (30 days, that September)
FFT bin at 7.5 Hz, phase π/3, t₀ = epoch ic1:c:w=1/15;m=2;phi=7/90
That bin as a provenance record (10 Hz, N=64, k=3, A=1.0, θ=1/6 turn) ic1:f:fs=10;N=64;k=3;t0=0;A=3ff0000000000000;th=1/6;q=1/16777216

4. Binary encoding

A fully fixed-width ID is impossible with unbounded denominators and no floor. The layout is fixed with a variable-length exact tail:

[1 byte header: version<<4 | type] [8 bytes big-endian sort key] [LEB128 tail]

types: 0x0 NEVER   0x1 ALWAYS  0x2 INSTANT  0x3 SPAN   0x4 PHASE
       0x5 PSET    0x6 CELL    0x7 CRON     0x8 FFTCOMP
       0x9 WINDOWED             0xA–0xF reserved

All varints are unsigned LEB128; signed values use zigzag. Rationals are num, den pairs in lowest terms.

URL form: IC1- + Crockford base32 of the binary ID (left-aligned to a 5-bit boundary; decoding accepts lowercase and the o/i/l confusables).

5. The civil lens (two layers, forced)

Calendar units are not fixed-length in physical time: leap-second days are 86 401 s, DST days 23/25 h, months vary. Worse, future leap seconds are not computable (IERS announces ~6 months ahead) and tzdata is political. Therefore "cron as a TAI phase class" is impossible in principle, and the protocol has two layers:

Policies (normative): - Leap seconds: exact table from 1972 (Layer A conversion); proleptic 10 s offset before 1972 (the 1958–72 rubber-second era is not modeled). The UTC lens maps 23:59:60 into its day's 86 401-s span. - DST spring-forward: nonexistent wall times raise; cron occurrences in the gap are skipped (Vixie behavior). - DST fall-back: ambiguous wall times take the requested fold (PEP 495, default first); cron fires on the first occurrence only. - ISO weeks: ISO-8601, Monday start, weeks 1–52/53.

6. Cron canonicalization

Equivalent cron expressions MUST produce identical IDs.

  1. Parse the five fields (minute, hour, day-of-month, month, day-of-week); fold names (JAN, MON), map dow 7→0; expand *, ranges, lists, /step (a bare N/step extends to the field top, Vixie-style) into per-field bitsets (bit index = value).
  2. Vixie dom/dow quirk: when both dom and dow are restricted, semantics are OR. Normalize to a disjunction of pure-AND records: (dom restricted, dow=*) ∪ (dom=*, dow restricted).
  3. Prune impossible records (e.g. dom ⊆ {30, 31} with month = {2}; Feb 29 is possible — leap years exist). All records pruned → NEVER.
  4. Drop records bitset-subsumed by others; dedupe; sort records by their 18-byte encoding. The sorted bitmask list + zone is the identity.
  5. Text projection (deterministic greedy per field): full range → *; otherwise repeatedly take the smallest uncovered element and emit the maximal-length arithmetic progression from it within the mask (ties → smallest step; progressions shorter than 3 render as ranges or singletons). */5 in minutes renders 0-55/5.

7. Word aliases

For speech and sharing, not ground truth (a hash of an unbounded ID space into fixed tuples cannot be injective).

8. The clock

now() returns (Instant, uncertainty). The system clock is assumed NTP-disciplined; the default uncertainty bound is a conservative 100 ms. Near a slot boundary the current state is genuinely ambiguous within the sync error: state_at(w, m, t, err) returns one state, or two when t is within err of a boundary. The ambiguity is surfaced, never hidden.

9. FFT layer

The mapping is frozen: two implementations given the same provenance MUST produce the same name.

Φ(w = P/2, m = 2, φ = (t₀ − (θ/2π)·P − P/4) mod P)

— "the pulse centered on every peak, as if it continued forever." (A slot width of 1/f_s fails whenever k ∤ N; the half-cycle form never does.) The §3 example: 7.5 Hz, θ = π/3, t₀ = 0 → ic1:c:w=1/15;m=2;phi=7/90, and cos(2π·7.5·t + π/3) = 1 exactly at pulse centers (t = 1/9 + n·2/15). - Windowed identity: the capture's component is Windowed(capture span, Φ) over the span [t₀, t₀ + N/f_s); amplitude rides alongside as data. The f: text form and 0x8 binary type carry provenance (f_s, N, k, t₀, A, θ, q). - A record is not a name for a set of time. §2.4 forbids amplitude from entering a name, and it does not: an f: record is a statement about a measurement, and its canonical projections are the eternal Φ and the Windowed, both independent of A and q. Two records differing only in amplitude are different records that name the same time. - Phases estimated as floats are snapped to a declared quantum (phase_quantum_turns, default q = 1/2²⁴ turn) — deterministic and part of the provenance. Exact-bin tones only; a non-bin tone leaks across bins, and each bin's name describes the analysis frame's component, not the underlying tone. - θ's canonical unit is turns, an exact rational in [0, 1); radians are irrational multiples of a turn and would drag the φ computation out of ℚ. Snapping is normative and happens before φ is computed: n = round_half_even(exact(θ)/q), θ = (n·q) mod 1, all in ℚ from the float's exact dyadic value. (Half-even, not half-up: language defaults differ — JS Math.round is half-up — and a tie must not fork the name.) A record's θ need not lie on its own q grid: an exactly-known phase such as 1/6 turn stays exact. - Amplitude, given the convention X[k] = (N/2)·A·e^{iθ}, is A = 2·sqrt(re² + im²)/N — the naive expression, not hypot and not fused multiply-add, because a sequence of correctly-rounded IEEE operations is reproducible while those are not. - Edge cases: k = 0 (DC) → ALWAYS + amplitude (the signed mean re/N, θ = 0; windowed, it is just the capture span); k = N/2 → θ ∈ {0, π}, i.e. th ∈ {0, 1/2}, with A = |re|/N and the sign carried by the phase. - Honesty: atan2 is only correctly rounded to within an ulp or so across libms, so a record certifies the analyzer's estimate of θ, not a platform-independent truth. The quantum absorbs the difference; given the record, the name that follows is exact. - Nyquist/aliasing (exact in ℚ): the ceiling is per-signal f_s/2. For f above it, r = f − f_s·round(f/f_s) (half-even), alias frequency |r|, conjugate phase when r < 0. Example: 15/2 Hz sampled at 10 Hz → 5/2 Hz, conjugated.

10. Schedule inference (normative sketch)

The inverse operation: names → time sets is the forward direction; this is observed time sets → names.

infer(timestamps, min_resolution, top_k, candidates=auto) → ranked (class, score, matched_fraction, phase_jitter).

11. Honest limits

  1. Countability: real-parameter intervals are uncountable; any naming scheme is countable. This protocol names exactly the ℚ-parameterized sets — dense in everything, exact for everything a computer, a cron daemon, or an FFT bin can specify — and rejects irrational periods rather than approximating silently.
  2. Cron ≠ phase class: future leap seconds are not computable; the two-layer split is forced, not chosen. (An optional idealized "proleptic-UTC" lens, under which any UTC cron is periodic with the 400-year Gregorian period of 12 622 780 800 s, may compile cron → PSet for users who accept the idealization; not implemented.) The same forcing makes a nominal step (§2.5) a step and not a length: "1 month" has no number of seconds until it is anchored, and the number it then has is stamped with the tables that produced it.
  3. Fixed width vs exact ℚ: pick two of {fixed width, exact rationals, no floor}. The encoding keeps exactness and the fixed 9-byte sortable prefix; the tail varies.
  4. Aliases can't be injective: registry-relative prefixes, one-way by design.
  5. Simultaneity: NTP error makes "the current state" ambiguous within ±err of boundaries; the API surfaces both candidates.

12. Versioning