# Temporal Interoperability KB — full text
## Broadcast Day
slug: broadcast-day · category: calendar
policy dependent
## Purpose
A **broadcast day** is a local calendar day that does not start at local
midnight. It runs from a declared **cutover hour** (the operational default in
this KB is 06:00 local) on calendar date D to the same cutover on date D+1.
Anything airing between midnight and the cutover is logged under the
**previous** broadcast day's label — a program airing at 01:00 local on
2026-03-09 is reported as part of the 2026-03-08 broadcast day, not 03-09.
This convention exists because overnight programming, sports that run past
midnight, and ad-log reconciliation all need a single day boundary that does
not fall in the middle of prime evening/overnight viewing. It is the calendar
analog of choosing where to draw a boundary in geo: someone must declare it,
and the declaration must travel with every result.
## Source and destination
Source: an [instant](/docs/the-168-axis/) (UTC epoch) plus an IANA zone.
Destination: a `broadcast_day` label (a local calendar date and its declared
cutover) and the `slot_set` of UTC hour-of-week slots that day occupies.
## Exactness: policy dependent
policy dependent because the result
depends entirely on a parameter the caller must supply — the cutover hour —
and because the number of UTC hours a broadcast day spans is **not fixed**:
it varies with the local DST calendar for that specific date. There is no
"just compute it" version of this conversion; the cutover is a business rule,
not a physical constant, and it must be declared and echoed on every result
(requested vs executed), exactly as the geo KB requires the requested and
executed geometry to both be visible.
## Algorithm
```ts
import {
broadcastDayOf,
broadcastDayInterval,
broadcastDayToSlots,
DEFAULT_CUTOVER_HOUR,
} from "@/lib/time/broadcast";
// Which broadcast day does this instant belong to, in this zone?
const day = broadcastDayOf(Date.parse("2026-03-09T05:30:00Z"), "America/New_York", 6);
// -> { broadcastDate: "2026-03-08", cutoverHour: 6, zone: "America/New_York" }
// A 00:30 local airing (before the 06:00 cutover) rolls back to the prior day.
// The true [start, end) UTC interval — calendar-aware, so it honours DST.
const spring = broadcastDayInterval({ broadcastDate: "2026-03-08", cutoverHour: 6, zone: "America/New_York" });
// utcHours: 23 — 2026-03-08 in America/New_York is the spring-forward day (clocks skip 02:00->03:00),
// so the 24 *local* hours from 06:00 to next-day 06:00 span only 23 UTC hours.
const fall = broadcastDayInterval({ broadcastDate: "2026-11-01", cutoverHour: 6, zone: "America/New_York" });
// utcHours: 25 — 2026-11-01 is the fall-back day; the 06:00-to-06:00 local window
// spans 25 UTC hours because 01:00-02:00 local occurs twice.
// The set of UTC hour-of-week slots the broadcast day touches (deduplicated).
const slots = broadcastDayToSlots({ broadcastDate: "2026-03-08", cutoverHour: 6, zone: "America/New_York" });
// slots.length === 23 for the spring day above; wrapsWeek is true only when the
// interval crosses Sun 23:00 UTC -> Mon 00:00 UTC.
```
An ordinary, non-DST broadcast day produces exactly 24 UTC hours and 24
distinct slots. The DST day is the interop hazard: a spring-forward broadcast
day is 23 UTC hours (23 slots), and a fall-back broadcast day is 25 UTC hours
— but because one UTC hour genuinely recurs, `broadcastDayToSlots` still
returns a **deduplicated** slot set (each UTC hour-of-week slot appears once),
so its length is 24, while `broadcastDayInterval.utcHours` correctly reports
25 elapsed hours. Consumers that need to distinguish "24 distinct slots" from
"25 hours elapsed" must read both fields — collapsing them loses the DST
signal.
## Parameters
## Outputs
`broadcastDayOf` returns the broadcast-day label. `broadcastDayInterval`
returns the true `[startMs, endMs)` UTC interval and its actual `utcHours`
(23, 24, or 25). `broadcastDayToSlots` returns the deduplicated UTC
hour-of-week slot set and a `wrapsWeek` flag for the rare case where the
interval crosses the Sunday 23:00 UTC to Monday 00:00 UTC boundary.
## Units and convention
Instants are epoch milliseconds; zones are IANA identifiers resolved through
luxon (the IANA tz database); UTC hour-of-week slots follow the same
convention as the rest of this KB — slot 0 = Monday 00:00 UTC.
## DST and disambiguation behavior
`broadcastDayInterval` computes the end of the day with a calendar-aware
"plus one day" operation (luxon `.plus({ days: 1 })`), which honours DST
rather than adding a fixed 24 hours. This is the correct behavior for a
broadcast day: a "day" here means one full local calendar day at the cutover
hour, whatever its true UTC duration turns out to be. `broadcastDayToSlots`
then steps hour by hour across that true interval and de-duplicates by UTC
slot, so the returned slot count reflects distinct hour-of-week buckets
touched, not raw elapsed hours.
## Quality and provenance
Every broadcast-day result should echo the cutover hour and zone it was
computed with — the requested parameters — alongside the executed
`broadcastDate`, `utcHours`, and slot set. A result missing the cutover hour
cannot be audited: a 1am airing logged under the wrong day is nearly always
traceable to an undeclared or silently-changed cutover.
## Edge cases
[Broadcast-day cutover is not universal](/edge-cases/broadcast-day-cutover-varies/):
some operations use 05:00, 02:00, or midnight, and sports/overnight feeds
often differ from entertainment scheduling — never assume 06:00.
[Spring-forward gap](/edge-cases/spring-forward-gap/) and
[fall-back fold](/edge-cases/fall-back-fold/) are exactly what produce the
23- and 25-hour broadcast days shown above.
[Half-hour offset zones](/edge-cases/half-hour-offset-zones/) (India, Sri
Lanka, Newfoundland) mean the cutover itself may not land on a UTC hour
boundary, so the resulting slot set can start mid-slot.
[Broadcast calendar month](/edge-cases/broadcast-calendar-month/) explains how
broadcast days roll up into a broadcast month/quarter that does not align to
the Gregorian calendar — see [Week Systems](/docs/week-systems/).
## Python parity
```python
from datetime import date, timedelta
from zoneinfo import ZoneInfo
def broadcast_day_of(instant_utc, zone: str, cutover_hour: int = 6) -> date:
local = instant_utc.astimezone(ZoneInfo(zone))
return local.date() - timedelta(days=1) if local.hour < cutover_hour else local.date()
```
The tested reference implementation is the TypeScript in `lib/time/broadcast.ts`;
this Python mirrors `broadcastDayOf` only — computing the true DST-aware
`[start, end)` interval requires walking `zoneinfo` transition data the same
way `broadcastDayInterval` walks luxon's, and is intentionally left to the TS
implementation as the single source of truth.
---
## Causal Time vs Physical Time
slug: causal-time-vs-physical-time · category: provenance
This distinction is almost entirely absent from time standards, and it is the one
that matters most for event sourcing and multi-agent systems.
Two events can carry UTC timestamps
```text
A = 12:00:03
B = 12:00:02
```
while the truth is that **A happened before B** — because of queue delays, retries,
clock skew, or distributed clocks. The canonical hour-of-week slot orders events on
the wall clock; it does not, and cannot, order them causally. Physical order and
causal order are different relations, and for correctness you often need the causal
one.
## Sometimes there is no order at all
Two events on independent nodes may have **no** happens-before relation — there is
no fact about which came first. Forcing a UTC total order on them invents an order
that is an artifact of clock skew, not causality.
A UTC timestamp always gives you a tiebreak. That is exactly the trap: it will
happily order two concurrent events, and the order is meaningless. Use the wall
clock for reporting; use a logical clock for ordering.
## Three standard tools (executable here)
The KB ships Lamport clocks, vector clocks, and hybrid logical clocks as tested
functions, so causal order is checkable, not just described.
```ts
import { vectorCompare, vectorTick, lamportTick, hlcLocal, hlcReceive, hlcCompare } from "@/lib/time";
// Vector clocks detect concurrency — the thing UTC cannot.
vectorCompare({ a: 1, b: 0 }, { a: 2, b: 0 }); // "before"
vectorCompare({ a: 1, b: 0 }, { a: 0, b: 1 }); // "concurrent" ← unordered
// Lamport gives a total order consistent with causality (no concurrency info).
lamportTick(4, [7, 2]); // 8 = max(local, received) + 1
// A hybrid logical clock (HLC) keeps causal order AND stays near physical time,
// even when a clock steps backwards (VM restore, NTP correction).
const a = hlcLocal({ physicalMs: 0, logical: 0 }, 1000); // sender at t=1000
const b = hlcReceive({ physicalMs: 990, logical: 0 }, a, 990); // receiver skewed low
hlcCompare(b, a) > 0; // true — b still sorts AFTER a despite the smaller clock
```
- **Lamport** — a monotonic counter, `max(seen) + 1`. A total order consistent with
causality, but it cannot tell you two events were concurrent.
- **Vector clocks** — one counter per node. `vectorCompare` returns
`before`, `after`, `equal`, or `concurrent`, so concurrency is explicit.
- **Hybrid logical clocks** — physical time plus a logical tiebreak; causal order
that stays close to UTC and survives a backwards clock step.
The tested reference implementation is `lib/time/causal.ts`.
## Where physical and causal time meet the slot
Slot the events on the wall clock for hour-of-week analysis and reporting, but keep
a causal stamp for anything order-sensitive:
[queue reordering](/edge-cases/message-queue-delay/),
[duplicate replay](/edge-cases/duplicate-event-replay/) (same event time, new
ingestion), and [event corrections](/edge-cases/event-versioning/) that keep the
occurrence time fixed while the value changes. The slot says *when on the clock*;
the logical clock says *in what order*. A temporal-interoperability standard for
agents and event streams has to carry both.
---
## Cycle vs Interval
slug: cycle-vs-interval · category: concepts
Two temporal objects in this KB look alike and behave completely
differently. An **hour-of-week slot** (0–167) is a position in a *repeating
weekly cycle*: slot 45 is "Tuesday 21:00 UTC" in *every* week, forever. A
**UTC window** like `[2026-11-01T05:00Z, 06:00Z)` is a concrete *interval*:
it happens once. Confusing the two is the temporal version of mixing a cell
*system* with a cell *id* — the values type-check as numbers and strings, so
nothing complains until a set operation quietly returns the wrong answer.
Every conversion result in this KB therefore carries a `temporal_kind`
discriminator: **`cycle`** or **`interval`**.
## The two kinds
A cycle value is incomplete on its own the way an H3 resolution is
incomplete without a cell id: "slot 45" is not a moment until you pair it
with an ISO week. An interval value is fully grounded — it names a specific
hour that already has (or will have) happened.
## Why the distinction is load-bearing
The two kinds support different operations, and mixing them silently
corrupts the result:
- **Set algebra only closes within one kind.** You can intersect or subtract
two intervals (concrete UTC spans) exactly. You can intersect two cycles
(slot-sets) exactly. You **cannot** subtract a cycle from an interval
without first *binding* the cycle to a specific week — the temporal analog
of the geo rule that set operations must stay inside one cell system.
- **Binding is where DST enters.** Turning a cycle into an interval —
"slot 2 of *this* week, in *this* zone" — is exactly where a 23- or
25-hour day, a skipped local hour, or a repeated local hour appears. A
cycle has no DST; the interval it binds to does. See
[Requested vs executed time](/docs/requested-vs-executed-time/).
- **Projecting the other way is lossy.** Dropping the week from an interval
to get "just the slot" is fine for an ordinary week and *wrong* for a
DST-variant one, because the skipped/repeated local hour has no stable
cyclic home. That is why coarsening is a declared step, never a default
([no silent temporal rollup](/edge-cases/no-silent-temporal-rollup/)).
Joining a table keyed by hour-of-week slot (a cycle) to one keyed by a UTC
timestamp (an interval) on the bare integer is the single most common
temporal interoperability bug. The slot repeats every week; the timestamp
does not. Bind the slot to each week — or aggregate the timestamp to a
(week × slot) key — before the join. Never join a cycle to an interval on
the raw number.
## What every tool declares
The live conversion tools tag each result so a consumer never has to infer
the kind from the field names:
The rule of thumb: if the result would be identical next week, it is a
**cycle**; if it names a specific year and week, it is an **interval**. The
`time_slot_to_utc_window` and `time_to_canonical` pair is the canonical
bridge between them — bind a cycle to a week to get an interval, and read an
interval's slot to get back the cycle.
---
## Daypart to Slots
slug: daypart-to-slots · category: calendar
weighted
## Purpose
A **daypart** — "primetime," "morning drive," "overnight" — is a band of
**local** clock hours on a set of weekdays. It is a scheduling and
measurement concept defined the way audiences experience it: 8pm feels like
primetime everywhere, regardless of what UTC hour that is. The canonical unit
in this KB, however, is the UTC hour-of-week slot. Converting a daypart to
slots therefore requires resolving each local hour through the tz database
for a **specific** IANA zone and a **specific** ISO week, because the
local-to-UTC offset shifts across DST — the same local daypart maps to a
different UTC slot set in a spring week than in a fall week.
## Source and destination
Source: a `daypart` definition (local hours + weekdays), an `iana_zone`, and
an `iso_week`. Destination: a `slot_set` of UTC hour-of-week slots.
## Exactness: weighted
weighted. Sub-hour offsets (India, Sri Lanka,
Newfoundland at half-hour offsets; Nepal, Chatham Islands at 45-minute
offsets) and dayparts whose edges fall on the half hour (a "daytime" band
running 9:30am-4:30pm local) both mean the local band does not always align
to whole UTC hours. When it doesn't, a single local hour straddles two UTC
slots, so the correct output is not a clean slot list but a **weighted
slot-set** — the fraction of coverage each UTC slot receives — the direct
temporal analog of the geo KB's weighted crosswalk for a polygon that
straddles a cell boundary. Rounding to whole-slot membership by majority
overlap is a documented, lossy simplification, not the default behavior.
## Algorithm
```ts
import { STANDARD_DAYPARTS, daypartToUtcSlots, utcSlotToDaypart } from "@/lib/time/daypart";
const primetime = STANDARD_DAYPARTS.find((d) => d.id === "primetime")!;
// { id: "primetime", name: "Primetime (8p-11p)", hours: [20, 21, 22] }
// Expand to UTC slots for a specific zone and ISO week (DST-correct for that week).
const janSlots = daypartToUtcSlots(primetime, "America/New_York", 2026, 3);
// Early January: EST is UTC-5, so 20:00-23:00 local -> 01:00-04:00 UTC next day.
const julySlots = daypartToUtcSlots(primetime, "America/New_York", 2026, 29);
// Mid-July: EDT is UTC-4, so the SAME local daypart resolves to a different
// UTC slot set than in week 3 — a one-hour shift purely from DST.
// Inverse: which daypart does a given UTC slot fall into, for a zone + week?
const back = utcSlotToDaypart(julySlots.slots[0]!, "America/New_York", 2026, 29);
// -> { daypart: "primetime", localHour: 20, localWeekday: }
```
`STANDARD_DAYPARTS` is a conventional US broadcast scheme (overnight, morning,
daytime, early fringe, early news, prime access, primetime, late news) defined
purely in **local hours**. It is a labelled default, not a universal
standard — Nielsen, individual networks, and international broadcasters each
define their own bands, sometimes at 15-minute resolution. Treat it the same
way this KB treats a conversion profile: override the hours/days per market
rather than assuming the US scheme applies elsewhere.
## Parameters
## Outputs
A `DaypartSlots` record: the daypart id, zone, ISO year/week, and the sorted,
deduplicated set of UTC hour-of-week slots the daypart occupies in that week.
Where a sub-hour offset or a non-hour-aligned band edge applies, treat that
set as coverage-weighted rather than binary membership — carry the overlap
fraction through to any downstream reporting rather than silently rounding.
## Units and convention
Local hours are integers 0-23; UTC slots follow the KB-wide convention (slot 0
= Monday 00:00 UTC); zones are IANA identifiers; the tz engine is luxon.
## DST and disambiguation behavior
`daypartToUtcSlots` resolves each (weekday, local hour) pair as a wall time in
the target ISO week via luxon, so it inherits the correct DST offset for that
specific week automatically. An hour skipped entirely by a spring-forward gap
(the local hour that never occurs) is silently excluded from the slot set
rather than raising — callers doing exact accounting (e.g. ad-slot inventory)
should independently check for gap weeks via [DST Handling](/docs/dst-handling/)
if that hour mattered to them.
## Quality and provenance
Every result should carry the zone, ISO week, and daypart definition used —
the same daypart id resolves to a different slot set in different weeks, so
the week is not optional context, it is part of the key.
## Edge cases
[Half-hour](/edge-cases/half-hour-offset-zones/) and
[45-minute](/edge-cases/forty-five-minute-offset-zones/) offset zones, and the
general [sub-hour band straddle](/edge-cases/sub-hour-band-straddle/) case,
are why the output is weighted rather than a clean partition.
[Southern-Hemisphere reversed DST](/edge-cases/southern-hemisphere-reversed-dst/)
means "primetime" in Sydney and New York shift in opposite calendar
directions across the year — never compare local-experience dayparts across
hemispheres by UTC slot alone; see
[Measurement Semantics](/docs/measurement-semantics/) for the general
[UTC-canonical-vs-local-experience](/edge-cases/utc-canonical-vs-local-experience/)
tension. [Extreme offset span](/edge-cases/extreme-offset-span/) means a
daypart aggregated across many zones can produce a slot set that wraps the
entire week.
## Python parity
```python
from zoneinfo import ZoneInfo
from datetime import datetime
def daypart_to_utc_slots(hours, days, zone: str, iso_year: int, iso_week: int) -> set[int]:
slots = set()
for day_mon0 in days:
for hour in hours:
try:
local = datetime.fromisocalendar(iso_year, iso_week, day_mon0 + 1).replace(
hour=hour, tzinfo=ZoneInfo(zone)
)
except ValueError:
continue # hour skipped by a spring-forward gap
utc = local.astimezone(ZoneInfo("UTC"))
weekday_mon0 = (utc.isoweekday() - 1)
slots.add(weekday_mon0 * 24 + utc.hour)
return slots
```
The tested reference implementation is the TypeScript in `lib/time/daypart.ts`;
this Python mirrors `daypartToUtcSlots` using `datetime.fromisocalendar` (3.9+)
and `zoneinfo`, catching the `ValueError` a gap produces the same way the TS
skips an invalid luxon `DateTime`.
---
## DST Handling
slug: dst-handling · category: systems
## Purpose
Daylight saving time (DST) breaks the assumption that local wall-clock time
maps one-to-one onto UTC instants. Twice a year, in every zone that observes
it, the mapping becomes either **undefined** (a wall time that never occurs)
or **two-valued** (a wall time that occurs twice). Every local-to-slot
conversion in this KB — [timestamp-to-slot](/docs/timestamp-to-slot/),
[broadcast day](/docs/broadcast-day/), [daypart-to-slots](/docs/daypart-to-slots/) —
must detect these two conditions explicitly and apply a declared policy,
never a silent guess.
## The spring-forward gap
When clocks move forward (e.g. America/New_York, 2026-03-08: 02:00 local
jumps straight to 03:00), every wall time in the skipped hour — 02:00
through 02:59 — **does not exist** as a local reading in that zone on that
date. Only one true instant exists on the far side of the gap.
## The fall-back fold
When clocks move back (e.g. America/New_York, 2026-11-01: 02:00 local
becomes 01:00 again), every wall time in the repeated hour — 01:00 through
01:59 — occurs **twice**: once at the pre-transition (larger) UTC offset and
once at the post-transition (smaller) offset. These are two distinct UTC
instants, one hour apart, that read identically on a local clock.
## Detection and policy
```ts
import { localToSlot } from "@/lib/time/slot";
// GAP: 2026-03-08 02:30 does not exist in America/New_York.
const gap = localToSlot({ year: 2026, month: 3, day: 8, hour: 2, minute: 30 }, "America/New_York", "earliest");
// -> { wasNonexistent: true, wasAmbiguous: false, disambiguationApplied: "earliest",
// utc: "2026-03-08T07:30:00.000Z", offsetMinutes: -240 }
// Only one valid instant exists on either side of the gap; both "earliest" and
// "latest" resolve to it (the post-transition instant, EDT/-04:00).
// FOLD: 2026-11-01 01:30 occurs twice in America/New_York.
const earliest = localToSlot({ year: 2026, month: 11, day: 1, hour: 1, minute: 30 }, "America/New_York", "earliest");
// -> wasAmbiguous: true, offsetMinutes: -240 (EDT, pre-transition, FIRST occurrence)
const latest = localToSlot({ year: 2026, month: 11, day: 1, hour: 1, minute: 30 }, "America/New_York", "latest");
// -> wasAmbiguous: true, offsetMinutes: -300 (EST, post-transition, SECOND occurrence)
// Same wall time, same zone -> two different UTC instants one hour apart,
// and therefore two different hour-of-week slots.
// REJECT: refuse to silently pick, useful where the caller must be forced to disambiguate.
try {
localToSlot({ year: 2026, month: 3, day: 8, hour: 2, minute: 30 }, "America/New_York", "reject");
} catch (e) {
// "Nonexistent local time (spring-forward gap): ... is skipped."
}
```
`localToSlot` returns `wasNonexistent` and `wasAmbiguous` flags plus
`disambiguationApplied` on every call, so a gap or fold is never invisible
even when a default policy quietly resolved it — the requested-vs-executed
record makes the disambiguation auditable (see
[Requested vs Executed Time](/docs/requested-vs-executed-time/)).
## Never hardcode the transition
It is tempting to hardcode "DST transitions happen at 02:00 local." They do
not, universally. The transition **hour** and **date** vary by zone: some
transition at 00:00, 01:00, or 03:00 local, or at 23:00 the prior day; dates
differ by country even within the same broad region; and the Southern
Hemisphere transitions in the opposite calendar months from the Northern
Hemisphere (Australia's DST begins in October and ends in April). Always
resolve transitions through the IANA tz database (luxon here), never a
constant.
## Partial-hour shifts
Not every DST transition moves the clock by a full hour. Lord Howe Island
(Australia) shifts by only 30 minutes (+10:30 standard to +11:00 DST), and
several historical transitions elsewhere used 20- or 40-minute shifts. A
30-minute gap or fold is genuinely half an hour of nonexistent or ambiguous
wall time, not a full slot's worth — compute the shift magnitude from the tz
database's actual transition data rather than assuming 60 minutes, since a
policy built for a one-hour fold will misapportion a 30-minute one.
## Reversed and absent DST
[Southern-Hemisphere reversed DST](/edge-cases/southern-hemisphere-reversed-dst/):
because the DST calendar flips by hemisphere, the same UTC slot corresponds
to a different local season (and often a different local hour) in Sydney
versus New York at the same time of year — local-experience comparisons
(see [Measurement Semantics](/docs/measurement-semantics/)) must never assume
a shared DST calendar across hemispheres.
[Non-DST region inside a DST country](/edge-cases/non-dst-region-inside-dst-country/):
Arizona observes no DST while the rest of US Mountain time does; Queensland
differs from New South Wales within Australia. A "Mountain Time" or country
label is ambiguous for roughly half the year in these cases — resolve by the
specific IANA zone (`America/Phoenix` vs `America/Denver`), never by a
country or a generic offset name.
## Quality and provenance
Every conversion through a gap or fold should carry, at minimum:
`wasNonexistent`, `wasAmbiguous`, `disambiguationApplied`, and the resolved
`offsetMinutes` — enough for a downstream consumer to know not just which
instant was chosen but that a choice was necessary at all. `lossless` (in
`lib/time/provenance.ts`) is `false` whenever either flag is set, marking the
conversion as one where requested and executed cannot both hold exactly.
## Edge cases
[Spring-forward gap](/edge-cases/spring-forward-gap/) and
[fall-back fold](/edge-cases/fall-back-fold/) are this page's core subject.
[Partial-hour DST shift](/edge-cases/partial-hour-dst-shift/),
[DST transition time varies by zone](/edge-cases/dst-transition-time-varies/),
[Southern-Hemisphere reversed DST](/edge-cases/southern-hemisphere-reversed-dst/),
and [non-DST region inside a DST country](/edge-cases/non-dst-region-inside-dst-country/)
are the specific failure modes of assuming a single, universal DST rule.
## Python parity
Python's `zoneinfo` + `datetime` handle the same two hazards via the
`fold` attribute (PEP 495) rather than a returned flag: `fold=0` selects the
first (pre-transition) occurrence of an ambiguous fold, `fold=1` selects the
second, and a nonexistent (gap) time is silently normalized forward when
`.astimezone()` is called on it.
```python
from datetime import datetime
from zoneinfo import ZoneInfo
zone = ZoneInfo("America/New_York")
# FOLD: fold=0 = earliest (EDT, pre-transition); fold=1 = latest (EST, post-transition).
earliest = datetime(2026, 11, 1, 1, 30, tzinfo=zone, fold=0)
latest = datetime(2026, 11, 1, 1, 30, tzinfo=zone, fold=1)
print(earliest.utcoffset(), latest.utcoffset()) # -4:00:00 then -5:00:00
# GAP: 2026-03-08 02:30 does not exist; .astimezone() resolves it forward.
nonexistent = datetime(2026, 3, 8, 2, 30, tzinfo=zone)
resolved = nonexistent.astimezone(ZoneInfo("UTC"))
```
`fold` is Python's disambiguation policy equivalent to this KB's
`"earliest"`/`"latest"` parameter; there is no built-in `"reject"` behavior in
`zoneinfo` — an application that needs to refuse ambiguous input must detect
the fold explicitly (compare the UTC offsets at `fold=0` and `fold=1`; if they
differ, the wall time is ambiguous) before deciding, the same detection
`localToSlot` performs internally. The tested reference implementation
remains the TypeScript in `lib/time/slot.ts`.
---
## Holidays
slug: holidays · category: calendar
approximate
## Purpose
A holiday flag answers "is this local calendar date a national holiday in
this country" — used to explain otherwise-anomalous demand, traffic, or
audience patterns without a manual lookup table. This KB computes holidays
from **rules** (fixed dates, the nth or last weekday of a month, and the
Gregorian Easter computus) rather than from a scraped or licensed calendar
feed. That makes the result small, auditable, and reproducible for any year,
at the cost of completeness: v0 covers US federal, UK bank, and Canada
national holidays only, and does not attempt regional, lunar, or
locally-observed holidays. Critically, a holiday is not a slot — it is a
**24-local-hour band**, i.e. a slot-set once mapped to UTC, exactly like a
[broadcast day](/docs/broadcast-day/).
## Source and destination
Source: a `local_date` (YYYY-MM-DD) and a `country` code. Destination: a
`holiday_flag` (matched holiday name, if any) and, when mapped to UTC for
measurement, a `slot_set` covering that local calendar date's 24 hours.
## Exactness: approximate
approximate. The rule set is exact for what
it models (a fixed date always falls on that date; an nth-weekday rule always
resolves the same way; the Easter computus is a well-defined deterministic
algorithm), but the **coverage** is approximate relative to "all holidays
that matter" in a given country: it omits state/provincial holidays,
substitute (in-lieu) days by default, and any lunar or movable holiday not
already enumerated. Treat a `false` result as "not flagged by this rule set,"
not as an authoritative "not a holiday anywhere in this jurisdiction."
## Algorithm
```ts
import { holidaysFor, isHoliday, easterSunday } from "@/lib/time/holidays";
holidaysFor("US", 2026).find((h) => h.name === "Thanksgiving");
// -> { date: "2026-11-26", name: "Thanksgiving", country: "US" }
// (4th Thursday of November — nthWeekday(year, month=11, isoWeekday=Thursday, n=4))
easterSunday(2026);
// -> { month: 4, day: 5 } (2026-04-05, via the Anonymous/Meeus computus)
isHoliday("2026-04-05", "UK");
// -> null — Easter SUNDAY itself is not a UK bank holiday; Good Friday
// (2026-04-03) and Easter Monday (2026-04-06) are, and are computed as
// offsets from easterSunday() rather than looked up separately.
isHoliday("2026-07-04", "US");
// -> { date: "2026-07-04", name: "Independence Day", country: "US" }
```
Each country's holiday list is a small, explicit array built from three
primitives: a literal fixed date (`iso(y, 12, 25)` for Christmas), an
nth-weekday rule (`nthWeekday(y, 1, 1, 3)` for the third Monday in January —
Martin Luther King Jr. Day), and a last-weekday rule (`lastWeekday(y, 5, 1)`
for the last Monday in May — Memorial Day). Easter-derived UK holidays
compute an offset in days from `easterSunday(y)` rather than encoding their
own date rule, so they stay correct in every year without a separate lookup
table.
## Parameters
## Outputs
`holidaysFor` returns every `Holiday` (`{date, name, country}`) the rule set
produces for that country and year. `isHoliday` returns the matching
`Holiday` or `null`. `easterSunday` returns `{month, day}` for the Gregorian
Easter Sunday of a given year — the anchor several UK holidays are computed
from.
## Units and convention
Holiday dates are local calendar dates (YYYY-MM-DD) in the country's own
civil calendar, not UTC instants. Mapping a holiday to the KB's canonical UTC
slots requires an explicit zone and produces a 24-hour slot-set (which, like
a [broadcast day](/docs/broadcast-day/), may be 23 or 25 UTC hours across a
DST transition in that country) rather than a single slot.
## DST and disambiguation behavior
Holiday date computation itself has no DST dependency — it is pure calendar
arithmetic. DST only enters once a holiday's local date is converted to a
UTC slot-set for measurement, at which point the same gap/fold handling as
[DST Handling](/docs/dst-handling/) applies to the conversion, not to the
holiday rule.
## Quality and provenance
This is intentionally a small, auditable rule set, not a substitute for an
authoritative feed anywhere legal observance matters (payroll, banking
closures, contractual SLAs). State the tzdb/rule-set version alongside any
holiday-flag output, and do not silently extend v0's three-country coverage
by inference — an unmodeled country should report "unknown," not "not a
holiday."
## Edge cases
[Substitute (in-lieu) holiday days](/edge-cases/substitute-day-holidays/): when
a fixed-date holiday lands on a Saturday or Sunday, many countries (UK, much
of APAC) observe a substitute weekday instead — this rule set does not apply
in-lieu substitution, so a fixed-date holiday falling on a weekend is flagged
on its nominal date only, which will disagree with the country's actual
observed closure date. [Movable and regional holidays](/edge-cases/movable-and-regional-holidays/):
lunar-calendar holidays (Eid, Diwali, Lunar New Year) shift against the
Gregorian calendar year to year and are not covered by this rule set at all;
regional holidays (US state, Canadian province) are omitted from the
national-only lists above.
## Python parity
```python
def easter_sunday(year: int) -> tuple[int, int]:
a = year % 19
b, c = divmod(year, 100)
d, e = divmod(b, 4)
f = (b + 8) // 25
g = (b - f + 1) // 3
h = (19 * a + b - d - g + 15) % 30
i, k = divmod(c, 4)
l = (32 + 2 * e + 2 * i - h - k) % 7
m = (a + 11 * h + 22 * l) // 451
month = (h + l - 7 * m + 114) // 31
day = (h + l - 7 * m + 114) % 31 + 1
return month, day # e.g. easter_sunday(2026) == (4, 5)
```
The tested reference implementation is the TypeScript in `lib/time/holidays.ts`;
this Python is the identical Anonymous/Meeus computus (integer division in
place of `Math.floor`), producing the same `(month, day)` for every year. The
fixed-date and nth/last-weekday rules translate directly using
`calendar.monthrange` or manual weekday arithmetic and are omitted here for
brevity — the algorithm is the same as `nthWeekday`/`lastWeekday` above.
---
## ISO Week
slug: iso-week · category: calendar
exact
## Purpose
This KB's canonical bucket for a moment is the pair (ISO week, hour-of-week
slot) — the ["168 axis"](/docs/the-168-axis/) coordinate. The slot repeats
every week (0-167), so it alone cannot identify a unique moment; it must be
paired with an unambiguous week identifier. ISO-8601 defines that identifier:
weeks start on Monday, and week 1 of an ISO year is the week containing that
year's first Thursday (equivalently: the week containing January 4th, or the
first week with at least four days in the new year). Both the week and the
slot are computed purely from the UTC instant, so the (isoYear, isoWeek,
slot) triple is timezone-independent and reproducible by any system that
implements ISO-8601 correctly.
## Source and destination
Source: an [instant](/docs/timestamp-to-slot/) (UTC epoch). Destination: an
`iso_week` (isoYear, isoWeek, isoWeekday) and a `week_slot_key` — the combined
"2026-W29-S045" string.
## Exactness: exact
exact. ISO-8601 week numbering is a deterministic
function of the calendar date with no external parameters, no DST
dependency, and no configurable policy — unlike [broadcast day](/docs/broadcast-day/)
or [daypart](/docs/daypart-to-slots/) conversions, there is nothing to
declare. The only discipline required is carrying the week-numbering **year**
alongside the week number, because — see below — it is not always the
calendar year.
## Algorithm
```ts
import { isoWeekOf, isoWeekStartUtc, isoWeeksInYear, weekSlotKey, parseWeekSlotKey } from "@/lib/time/isoweek";
isoWeekOf(Date.parse("2026-07-16T12:00:00Z"));
// -> { isoYear: 2026, isoWeek: 29, isoWeekday: 4 } (Thursday)
// The week-year/calendar-year mismatch around January 1:
isoWeekOf(Date.parse("2027-01-01T00:00:00Z"));
// -> { isoYear: 2026, isoWeek: 53, isoWeekday: 5 }
// 2027-01-01 is a Friday that falls in the LAST ISO week of 2026, not week 1 of 2027,
// because that week's Thursday (2026-12-31) is still in 2026.
// 2026 is a 53-week ISO year:
isoWeeksInYear(2026); // -> 53
// Reverse: the UTC instant that begins a given ISO week.
isoWeekStartUtc(2026, 29); // -> epoch ms of Monday 2026-07-13T00:00:00Z
// The combined (week x slot) key used as the canonical bucket everywhere in this KB.
weekSlotKey(Date.parse("2026-07-16T21:00:00Z"));
// -> { isoYear: 2026, isoWeek: 29, slot: 93, key: "2026-W29-S093" }
parseWeekSlotKey("2026-W29-S045");
// -> { isoYear: 2026, isoWeek: 29, slot: 45, key: "2026-W29-S045" }
```
## Parameters
## Outputs
`isoWeekOf` returns `{isoYear, isoWeek, isoWeekday}` (weekday 1=Monday through
7=Sunday). `weekSlotKey` returns the same plus the UTC hour-of-week `slot`
(0-167) and the canonical string `key`, formatted `YYYY-Www-Sss` with
zero-padded week and slot (e.g. `2026-W29-S045`). `isoWeeksInYear` returns 52
or 53. `isoWeekStartUtc` returns the epoch ms of the Monday 00:00 UTC that
begins the given week — the anchor that turns a repeating slot into a unique
instant (see [Slot-to-UTC-Window](/docs/slot-to-utc-window/)).
## Units and convention
Instants are epoch milliseconds; ISO weekdays are 1 (Monday) through 7
(Sunday); slots follow the KB-wide convention (slot 0 = Monday 00:00 UTC),
which is deliberately aligned with the ISO week's Monday start so that slot 0
of any week is always that week's opening instant. The tz engine is luxon,
computed here in the UTC zone (ISO week numbering itself has no zone
dependency once the instant is fixed).
## DST and disambiguation behavior
None. ISO week computation operates on the UTC instant only; DST is a
local-time phenomenon that affects how a *local* wall-clock reading maps to
that instant ([timestamp-to-slot](/docs/timestamp-to-slot/)), not how the
instant maps to its ISO week.
## Quality and provenance
Because this conversion is exact and parameter-free, the main provenance
requirement is structural: never persist or transmit a bare week number.
Always carry `isoYear` alongside `isoWeek`, and prefer the combined `key`
string as the join key between systems — it is self-describing and avoids
the year-boundary bug below by construction.
## Edge cases
[Week-year boundary](/edge-cases/week-year-boundary/): the ISO week-numbering
year diverges from the calendar year in the days around January 1 in either
direction — `2026-12-31` can fall in `2027-W01`, and `2027-01-01` falls in
`2026-W53`, per the runnable example above. A system that logs "week 53" or
"week 1" without its year is unreconcilable across this boundary.
[Fifty-three-week years](/edge-cases/fifty-three-week-years/): 2026 has 53
ISO weeks (a year has 53 ISO weeks when it starts on a Thursday, or is a leap
year starting on Wednesday); code that hardcodes 52 weeks per year will
misalign year-over-year comparisons in a 53-week year.
[Week-numbering systems](/edge-cases/week-numbering-systems/): ISO is not the
only week system in use — see [Week Systems](/docs/week-systems/) for the
comparison to US/retail and broadcast weeks.
[Slot-origin convention](/edge-cases/slot-origin-convention/): the KB's slot 0
is defined to align with the ISO week's Monday start; a system assuming a
Sunday-start week or local-midnight origin will be off by a fixed offset.
[Date-line weekday divergence](/edge-cases/date-line-weekday-divergence/):
near the International Date Line, a local weekday can differ from the
weekday implied by the UTC instant's ISO week — this page's `isoWeekday` is
always the UTC weekday, not any local one.
## Python parity
```python
from datetime import datetime, timezone
def iso_week_of(epoch_ms: int) -> tuple[int, int, int]:
dt = datetime.fromtimestamp(epoch_ms / 1000, tz=timezone.utc)
iso_year, iso_week, iso_weekday = dt.isocalendar()
return iso_year, iso_week, iso_weekday
def week_slot_key(epoch_ms: int) -> str:
iso_year, iso_week, iso_weekday = iso_week_of(epoch_ms)
dt = datetime.fromtimestamp(epoch_ms / 1000, tz=timezone.utc)
slot = (iso_weekday - 1) * 24 + dt.hour # isoWeekday 1=Mon..7=Sun -> Mon0
return f"{iso_year}-W{iso_week:02d}-S{slot:03d}"
```
Python's built-in `datetime.isocalendar()` (3.9+) implements ISO-8601 week
numbering natively and agrees with `isoWeekOf` for every instant, including
the 53-week and year-boundary cases above — no library beyond the standard
`datetime` module is required for this conversion. The tested reference
implementation remains the TypeScript in `lib/time/isoweek.ts`.
---
## Measurement Semantics
slug: measurement-semantics · category: semantics
The geo-interoperability KB makes a point of stating that geometry does
not fully define a measurement target — two systems can agree on a
polygon and still disagree about what counts as "inside" it. The temporal
side has the direct analog: two systems can agree on the exact same
canonical (ISO week x slot) coordinate and still be measuring two
different things. Getting the slot right is necessary; it is not
sufficient.
## UTC-canonical vs. local experience
The canonical unit of this KB is UTC. But almost every human-meaningful
temporal concept — primetime, lunch, the morning commute, "8pm" — is a
**local** experience, defined relative to the sun and the clock on the
wall, not relative to Greenwich. Holding the UTC slot fixed across
markets does not hold the local experience fixed: slot 69 (Wednesday
21:00 UTC) is prime-time evening in New York, mid-afternoon in Los
Angeles, and the middle of the Thursday morning commute in Mumbai. A
cross-market comparison that joins on the bare UTC slot and interprets the
result as "the same daypart everywhere" has silently substituted a UTC
fact for a local claim.
Comparing behavior "at the same UTC slot" across zones answers a
UTC-canonical question. Comparing behavior "at 8pm local" across zones
answers a different, local-experience question — and requires computing
a *different* UTC slot per zone per week, not reusing one slot for every
market. Declare which question is being asked before running the
comparison; see [half-hour-offset-zones](/edge-cases/half-hour-offset-zones/)
and [Daypart to slots](/docs/daypart-to-slots/) for how a local band is
translated into per-zone UTC slot-sets correctly.
This is the direct temporal analog of requested-vs-executed geography:
the local experience is what was *meant*; the UTC slot is what got
*executed* and stored. See
[Requested vs. executed time](/docs/requested-vs-executed-time/) for the
mechanics of that resolution and [Resolution and grain](/docs/resolution-and-grain/)
for choosing between UTC-indexed and local-indexed reporting up front.
## Event, ingestion, and report time
A single record commonly carries three distinct instants, and reports
routinely mix them without saying so:
Each of the three resolves to a different slot in general, and the gap
between event time and ingestion time is rarely constant — batch
pipelines, retry queues, and offline device sync all introduce variable
lag, so "ingestion slot" is not a fixed offset from "event slot" that can
be corrected after the fact with a single constant. Any slot-indexed
dataset must declare which of the three timestamps defines the slot
(`event-vs-ingestion-vs-report-time`); the default in this KB is event
time, but a system built on top of a feed that only reliably provides
ingestion time must say so explicitly rather than label the result "event
slot" by convention.
## Attribution-window time
Attribution introduces a fourth candidate: when a conversion is credited
to an earlier impression under a click-through or view-through attribution
model, the "time" of the conversion event is ambiguous between the
conversion's own timestamp and the timestamp of the impression it is
attributed to (`attribution-window-time`). A conversion that happens at
23:50 on Friday but is attributed to a Tuesday-morning impression could
reasonably be slotted either as "Friday night" (event time) or "Tuesday
morning" (attributed time) — and a report that does not declare which it
used cannot be reconciled against a second report using the other
convention, even though both reports are internally consistent. State
whether the slot is the conversion's own event time or the attributed
impression's time, and retain both fields rather than discarding one.
## No silent rollup, and slot-boundary dedup
Two measurement-specific failure modes round out this page, both of which
are about what happens *after* a correct slot has already been computed.
[No-silent-temporal-rollup](/edge-cases/no-silent-temporal-rollup/):
a buyer who requests hourly delivery or reporting and receives a report
silently computed at day or week grain has been given an answer to a
coarser, unstated question — see
[Resolution and grain](/docs/resolution-and-grain/) for the discipline
this requires at every grain choice in the pipeline, not only at the
final report.
[Slot-boundary dedup](/edge-cases/slot-boundary-dedup/): an event with
duration — a session, a video view, a linear ad airing — that spans a
:00 boundary can be double-counted (attributed to both slots it touches)
or dropped (attributed to neither, if the assignment rule implicitly
assumes instantaneous events) unless the assignment rule is declared:
start-time, end-time, or overlap-weighted apportionment, deduplicated by
`(key, slot)` so a single spanning event contributes to a slot's count at
most once under whichever rule was chosen.
## The core tension, stated plainly
Two platforms can both report "traffic at slot 69, week 2026-W30" and
mean genuinely different things: one measured event time from its own
ingestion pipeline with a 4-hour batch lag folded in unnoticed; the other
measured attributed conversions credited back to a click that happened in
a different slot entirely. The slot matching perfectly is not evidence
the measurements agree — it is only evidence that both systems can
compute the same arithmetic. Reconciling two slot-indexed datasets
requires reconciling the semantics behind the slot — which time defines
it, whether it reflects UTC-canonical or local-experience intent, whether
attribution shifted it — before the numbers themselves can be compared,
exactly as the geo KB requires reconciling *what counts as inside a
polygon* before two coverage numbers can be compared, not just agreeing on
the polygon's coordinates.
---
## Requested vs. Executed Time
slug: requested-vs-executed-time · category: concepts
The geo-interoperability KB draws a hard line between what a buyer
**requested** (a polygon, a radius, an address) and what a system
**executed** (a set of H3 cells, a circumscribed circle). This page ports
that doctrine to time. A local wall time — "8:00 PM in Chicago" — is a
request. The canonical UTC slot it resolves to is the execution. Most of
the time the two agree so cleanly that the distinction feels academic.
Twice a year, for one hour each, they cannot both be honored exactly, and
that is precisely when a system's honesty is tested.
## Why they can diverge
Daylight saving time transitions create two structurally different
failure modes, both handled by `localToSlot` and surfaced end-to-end by
`resolveLocalToCanonical` in `lib/time/`:
**Spring-forward gap.** At the DST-start transition, clocks jump forward
an hour and a whole range of wall times simply never occurs. `America/New_York`
skips from 01:59:59 directly to 03:00:00 on 2026-03-08, so `02:30` that
morning is not a request that can be executed as-written — it names a
moment that does not exist on that clock. There is exactly one adjacent
valid instant (03:30 EDT, the same clock position measured forward), and
the system's only honest choices are: resolve forward to it, resolve
backward to the pre-gap instant, or reject the request outright.
**Fall-back fold.** At the DST-end transition, clocks repeat an hour, so
a wall time names two different instants. `America/New_York` repeats
01:00–01:59 on 2026-11-01, once at UTC-4 (EDT) and once at UTC-5 (EST).
`01:30` that morning is ambiguous between two UTC instants an hour apart
— and, because slots are hour granular, potentially two different slots.
A system that picks a resolution for a gap or fold without a declared
policy, and without recording that it did so, has quietly converted an
ambiguous or invalid request into a false-precision answer. The record
must carry `wasNonexistent` / `wasAmbiguous` and the policy applied —
never just the resolved instant.
## The policy parameter
Both hazards are resolved by a declared `disambiguation` policy, not a
default buried in a library:
Other zone examples clarify the two hazards further: `05:30` in
`Asia/Kolkata` never touches a DST transition at all — India has
observed a fixed +5:30 offset since 1945 — so it resolves unambiguously
to `00:00 UTC`, which is slot 0. A zone inferred from geography (see
`inferred-timezone-from-geo`) inherits whichever policy its wall time
requires only if the zone itself is correctly resolved first — a wrong
zone produces a confidently wrong slot with no `wasAmbiguous` flag to
catch it, because the ambiguity was in the zone lookup, not the clock
arithmetic.
## Worked example
```ts
import { resolveLocalToCanonical } from "@/lib/time/provenance";
// Fall-back fold: 2026-11-01 01:30 America/New_York occurs twice.
const earliest = resolveLocalToCanonical(
{ year: 2026, month: 11, day: 1, hour: 1, minute: 30 },
"America/New_York",
"earliest",
);
// earliest.executed.utc -> "2026-11-01T05:30:00.000Z"
// earliest.executed.wasAmbiguous -> true
// earliest.executed.lossless -> false
const latest = resolveLocalToCanonical(
{ year: 2026, month: 11, day: 1, hour: 1, minute: 30 },
"America/New_York",
"latest",
);
// latest.executed.utc -> "2026-11-01T06:30:00.000Z"
// same requested wall time, one hour and one slot apart
// Spring-forward gap: 2026-03-08 02:30 America/New_York never occurs.
const gap = resolveLocalToCanonical(
{ year: 2026, month: 3, day: 8, hour: 2, minute: 30 },
"America/New_York",
"earliest",
);
// gap.executed.utc -> "2026-03-08T07:30:00.000Z"
// gap.executed.wasNonexistent -> true
// gap.executed.lossless -> false
```
`resolveLocalToCanonical` returns a `requested` object (the wall time,
zone, grain, and disambiguation policy exactly as asked) and an `executed`
object (the resolved slot, week-slot key, UTC instant, offset, which
disambiguation was actually applied, and a `lossless` boolean that is
`false` whenever `wasNonexistent` or `wasAmbiguous` is true). No field is
overwritten or dropped — a caller who only reads `executed.slot` gets a
correct answer; a caller who needs to know whether that answer required a
judgment call reads `executed.lossless` and `provenance` alongside it.
## Never silently rolled up
The same discipline extends past DST into grain: if a caller asks for
hour-of-week slot delivery and the system can only report at a coarser
grain — day or week — that coarsening must be a declared, requested
operation, never a silent default (`no-silent-temporal-rollup`). A report
that says "daily" when the caller asked for "hourly" has the same shape of
dishonesty as a slot resolved from a fold without recording which
occurrence was chosen: both replace an exact answer to the question asked
with an approximate answer to a different, unstated question. See
[Resolution and grain](/docs/resolution-and-grain/) for the decision
guide on choosing a grain up front, and
[Timestamp to slot](/docs/timestamp-to-slot/) for the full conversion
this page's doctrine governs.
---
## Resolution and Grain
slug: resolution-and-grain · category: concepts
Choosing a time grain is choosing how much of the underlying signal a
model or report is allowed to see, and that choice should be made
deliberately, once, up front — not discovered after the fact because a
platform's export happened to round every timestamp to the day. This page
lays out the grain ladder this KB works with and the considerations that
should drive a choice at any given rung.
## The grain ladder
Coarser is not automatically safer, and finer is not automatically
better: each rung trades away a specific kind of resolution to gain a
specific kind of stability, and the right choice depends on what the
downstream consumer is actually going to do with the number.
## Choosing a grain
**Statistical power.** A marketing-mix model regressing weekly spend
against weekly outcome needs enough independent weekly observations to
fit a stable coefficient — commonly 104+ weeks (two years) to resolve
seasonality separately from a media effect. Daily or hourly grain for the
same regression multiplies the row count but does not multiply
independent information at the same rate, because adjacent hours within a
day are highly autocorrelated; the extra rows buy resolution on
within-week shape, not more independent evidence for the weekly
coefficient. A day-part causal test (does a 6pm-9pm flight lift traffic
versus a 9pm-midnight flight) needs the opposite: fine enough grain
(hour-of-week slot) that the two windows are actually distinguishable in
the data, because collapsing both into a single "evening" daypart erases
the very contrast the test is designed to detect.
**Privacy.** Individual-level event timestamps at instant grain are
higher-risk for re-identification than the same events aggregated to a
slot or a week, because a rare instant (a single visit at 3:14:07am) can
be a fingerprint in a way that "visited during slot 3" is not. Aggregating
to a coarser grain before an inventory leaves controlled infrastructure is
a defensible privacy control — but it must be declared, not discovered
downstream by an analyst wondering why every hour looks identical within
a day.
**Platform reporting grain.** Ad platforms, POS systems, and BI tools
each report at their own native grain, and that grain is frequently
coarser than the canonical slot even when the underlying event stream is
finer — a POS system might expose "daily transaction count" with no
hourly breakdown available at all. The platform's native grain caps the
finest grain any analysis built on that feed can honestly claim,
regardless of what grain the modeling question would prefer.
## Decision guide
If a caller requests hour-of-week slot delivery and the system can only
produce day or week grain, that is a rejection or a renegotiation, not a
quiet substitution. A report labeled "hourly" that is actually daily
under the hood is not a rounding error — it silently answers a different
question than the one asked, and no downstream consumer can detect the
substitution without re-deriving the grain from first principles.
## Truncation is a one-way door
Timestamp rounding and truncation (`timestamp-rounding-truncation`) is the
data-quality version of the same problem: a timestamp stored with only
day-level precision — common in older warehouses or privacy-truncated
exports — cannot be placed in a slot at all, because the hour-of-week
information was discarded before the record ever reached this pipeline.
There is no recovery step for this; the claimed grain of any downstream
analysis must be capped at the coarsest grain any input column actually
supports, and a system that reports slot-level granularity built on top
of day-truncated inputs is fabricating precision it does not have. When
in doubt, treat the finest grain any single input column supports as a
hard ceiling on the finest grain the whole pipeline may claim, and state
that ceiling explicitly in any report — see
[Measurement semantics](/docs/measurement-semantics/) for how this
compounds with event-vs-ingestion-vs-report time ambiguity, and
[The 168 axis](/docs/the-168-axis/) for the canonical unit this ladder is
built around.
---
## Slot to UTC Window
slug: slot-to-utc-window · category: from-canonical
exact
This is the inverse of [Timestamp to slot](/docs/timestamp-to-slot/): given
a canonical (ISO week x slot) coordinate, recover the concrete UTC
interval it names. Unlike the forward conversion, this direction carries
no DST ambiguity at all — it is pure, deterministic arithmetic over a
timeline that has no gaps or folds in it, because UTC itself does not
observe daylight saving time.
## Purpose
Convert a `hour_of_week_slot` (0–167) plus an ISO week (`isoYear`,
`isoWeek`) into the concrete `utc_interval` — a half-open `[start, end)`
range spanning exactly one UTC hour — that the slot names within that
specific week. This is the conversion that turns an abstract, repeating
coordinate back into a schedulable, queryable moment: "run this campaign
during 2026-W30-S069" only means something once it is resolved to
`[2026-07-22T21:00:00Z, 2026-07-22T22:00:00Z)`.
## Source and destination
## Algorithm
```ts
import { isoWeekStartUtc } from "@/lib/time/isoweek";
import { slotToInstant, normalizeSlot } from "@/lib/time/slot";
function slotToUtcWindow(
isoYear: number,
isoWeek: number,
slot: number,
): { startUtc: string; endUtc: string; startMs: number; endMs: number } {
const weekStartMs = isoWeekStartUtc(isoYear, isoWeek); // Monday 00:00 UTC
const startMs = slotToInstant(weekStartMs, normalizeSlot(slot));
const endMs = startMs + 3_600_000; // exactly one UTC hour, always
return {
startUtc: new Date(startMs).toISOString(),
endUtc: new Date(endMs).toISOString(),
startMs,
endMs,
};
}
// The inverse of the worked example on "The 168 axis":
const window = slotToUtcWindow(2026, 30, 69);
// window.startUtc -> "2026-07-22T21:00:00.000Z"
// window.endUtc -> "2026-07-22T22:00:00.000Z"
```
`isoWeekStartUtc` anchors the repeating slot to a specific week by
resolving Monday 00:00 UTC of that ISO week; `slotToInstant` then adds
`slot * 3,600,000` milliseconds. Because both steps operate purely in UTC
— no zone lookup, no local calendar — the result is deterministic for
every valid `(isoYear, isoWeek, slot)` triple, and normalizing the slot
with `normalizeSlot` makes the function total over any integer input
rather than throwing on an out-of-range value.
## No-silent-rollup
If a caller asks for the UTC window of a specific slot and the serving
system can only resolve to a day or week boundary — for example, a
reporting table that only stores daily rollups — the correct response is
an explicit rejection or a renegotiated grain, never a silently widened
window. A caller who asked for a one-hour window and received an
undisclosed 24-hour window has been given an answer to a coarser question
than the one asked, and nothing in the response shape reveals that a
substitution occurred.
This mirrors the geo KB's no-silent-rollup rule for cell aggregation:
just as a system must never quietly return an H3 R5 cell's centroid when
an R8 point was requested, this conversion must never quietly return a
day-level window when an hour-level slot was requested. See
[Resolution and grain](/docs/resolution-and-grain/) for how to negotiate
grain up front so this situation is rare rather than a runtime surprise.
## Quality and edge cases
The conversion is exact to the millisecond for any valid input, with one
qualification: [leap seconds](/edge-cases/leap-seconds/). UTC has
inserted 27 leap seconds since 1972 (with insertions expected to be
phased out by around 2035), making an occasional UTC day 86,401 seconds
rather than 86,400; cloud providers that "smear" the leap second across a
24-hour window can disagree with strict UTC by up to roughly half a
second during the smear. For hour-of-week bucketing this is immaterial —
a half-second discrepancy never crosses an hour boundary — but a system
performing sub-second joins against this window (aligning a video frame
or a bid-request timestamp to the hour boundary, say) should declare its
clock model (UTC, TAI, or a specific smear algorithm) explicitly rather
than assume all "UTC" timestamps in a join are measured against the same
clock.
[No-silent-temporal-rollup](/edge-cases/no-silent-temporal-rollup/) is the
single most consequential edge case for this conversion precisely because
the conversion itself has no failure mode of its own — the arithmetic is
exact — so the entire risk surface sits in how the resulting window is
reported downstream. Pair every UTC window this conversion returns with
the ISO week and slot it was derived from, so a consumer can always
verify the window matches the grain it originally requested. See
[The 168 axis](/docs/the-168-axis/) for the slot definition this
conversion inverts, and [ISO week and the week-slot key](/docs/iso-week/)
for how `isoWeekStartUtc` anchors the week boundary this window is
computed relative to.
---
## The Temporal Interoperability Model
slug: temporal-interoperability-model · category: concepts
Every temporal fact this knowledge base handles enters as a local
observation and leaves as a canonical coordinate. The path between those
two states is fixed, ordered, and — this is the point of the KB — never
skipped or collapsed. This page names the four stages and states why
collapsing them produces silent, systematic errors rather than obviously
broken output.
## The four stages
**1. Timestamp + zone (requested).** The raw input is a wall-clock
reading — `2026-07-29 14:30` — paired with an IANA zone identifier,
`America/Chicago`. Neither number nor string alone means anything; a wall
time without a zone is not a moment in time, it is a pattern that could
match any of roughly forty distinct instants depending on which zone
resolves it.
**2. Resolve to a UTC instant (DST-correct).** The (wall, zone) pair is
resolved through the IANA time zone database to a single point on the UTC
timeline — an epoch millisecond value. This step is where daylight saving
time lives: most wall times resolve to exactly one instant, but twice a
year a local clock either skips an hour (spring-forward gap, the wall time
never occurs) or repeats one (fall-back fold, the wall time occurs twice).
Both hazards are resolved by a declared policy, not a default — see
[Requested vs. executed time](/docs/requested-vs-executed-time/).
**3. Hour-of-week slot 0–167.** The UTC instant is bucketed into one of
168 hour-of-week slots, where slot 0 is Monday 00:00 UTC and the slot is a
pure function of the instant: `weekdayMon0 * 24 + hourUTC`. This is the
canonical unit of the whole KB — see [The 168 axis](/docs/the-168-axis/).
A slot alone repeats every week; it identifies a position in the cycle,
not a moment.
**4. (ISO week x slot) key.** Pairing the repeating slot with an ISO
8601 week number produces a unique, sortable key — `2026-W29-S045` —
that identifies exactly one hour, once, forever. This is the join key
every downstream table, model feature, and report is built on.
**5. Executed / reported grain.** Only at the very last step does the
canonical key get rolled up or annotated for a specific consumer: a
broadcast day, a daypart label, a weekly aggregate for an MMM model. That
rollup is a declared, requested operation — never a silent default — as
covered in [Resolution and grain](/docs/resolution-and-grain/).
```mermaid
flowchart LR
A["Local timestamp + IANA zone
(REQUESTED)"] --> B["Resolve to UTC instant
DST gap/fold: declared policy"]
B --> C["Hour-of-week slot 0-167
instantToSlot(epochMs)"]
C --> D["ISO week x slot key
2026-W29-S045
weekSlotKey(epochMs)"]
D --> E["Executed / reported grain
instant | slot | day | week"]
style A fill:#334,stroke:#88a
style D fill:#343,stroke:#8a8
```
This is the direct temporal analog of the geo KB's requested-vs-executed
geography doctrine. A buyer asks for "8pm local in Chicago." The system
executes a UTC instant. Those are two different facts about the same
event, and the record should carry both rather than pretending the second
derives losslessly from the first.
## Why the stages must stay distinct
Collapsing stages 1 and 3 — treating a local hour as if it were the slot
— breaks the moment a dataset spans more than one time zone, because
"2pm" in New York and "2pm" in Los Angeles are three slots apart. Collapsing
stages 3 and 4 — reporting a bare slot without its ISO week — breaks the
moment a report spans more than one week, because slot 45 recurs 52 or 53
times a year and a bare slot cannot distinguish this Tuesday from next
Tuesday. Collapsing stage 5 into stage 4 — silently rolling a time slot up
to a day or week grain — breaks any consumer that asked for hourly
delivery and received a coarser one without being told, which is why
`no-silent-temporal-rollup` is one of the two edge cases this page flags
explicitly.
Each stage also has its own, non-overlapping failure mode, which is the
practical reason to keep them separate in code and in schema rather than
fusing them into one "parse the timestamp" function: stage 2 fails on DST
ambiguity, stage 3 fails on origin-convention mismatches (a Sunday-start
week is off by 24 slots), and stage 4 fails on ISO week-year boundary
bugs. A pipeline that fuses all three into one opaque conversion cannot
report *which* stage produced a wrong answer.
## Doctrine
Nate's doctrine from the geo side of this KB ports over unchanged: **the
free converter canonicalizes the key; everything indexed by it is the
product.** A single (ISO week x slot) key is cheap to compute and free to
expose — the conversion in stages 1 through 4 above. What is valuable is
everything built on top of that key once it is trustworthy: demand curves
keyed by slot, causal tests that hold the slot fixed across markets,
attribution windows measured in slots, and audience models trained on
slot-indexed features. The KB gives away the coordinate system for free
and monetizes the models that are indexed by it — exactly as the geo KB
gives away H3 cell math and monetizes the audience layer built on H3.
## Edge cases affecting this page
The [UTC-canonical-vs-local-experience](/edge-cases/utc-canonical-vs-local-experience/)
tension — that "primetime" is a local concept while the canonical unit is
UTC — is the single most common source of misapplied comparisons across
this pipeline, and is discussed in full in
[Measurement semantics](/docs/measurement-semantics/).
[Tzdb-vintage mismatch](/edge-cases/tzdb-vintage-mismatch/) affects stage
2 specifically: two systems on different IANA tz database releases can
resolve the identical (wall, zone) pair to different UTC instants near a
changed transition, so the tz database version belongs in the provenance
record alongside the resolved instant, not just in a changelog somewhere.
---
## Temporal Provenance
slug: temporal-provenance · category: provenance
The geo KB treats **provenance** as a first-class object: a geometry carries its
source, CRS, and boundary vintage, and a conversion that loses them is broken.
Time deserves the same treatment, and this is where temporal interoperability
stops being calendaring and becomes a distributed-systems and AI-pipeline problem.
A value like `2026-07-12T14:31:02Z` looks complete. It is not. It hides **which**
clock produced it, **how accurate** that clock was, **what timescale** it is on,
and **which stage** of a pipeline the instant refers to.
## A timestamp is a lifecycle, not a moment
The same datum passes through many times, and any of them can be mistaken for the
canonical event time:
Two systems can pick different stages as canonical from the same record and land
in different hour-of-week slots. So the choice of canonical stage must travel with
the value, and the gap between `observedTime` and `storedTime` is the pipeline
latency — a real quantity, not a rounding error.
## The clock is part of the value
`performance.now()` and `CLOCK_MONOTONIC` measure elapsed time from an arbitrary
origin. They have no fixed epoch, so they cannot be converted to UTC or a slot at
all. Mixing a monotonic reading into a wall-clock column silently corrupts every
latency and ordering computation downstream.
## The model, made checkable
The KB ships this as an executable type, not just prose. A `TemporalProvenance`
record is validated: monotonic values are rejected as unmappable, a backwards
lifecycle step is flagged (a VM snapshot restore, an offline replay, an NTP jump,
or a corrected timestamp), the canonical stage must be present, and sub-second
digits from a coarse clock are flagged as false precision.
```ts
import {
provenanceIssues,
canonicalInstant,
pipelineLatencyMs,
type TemporalProvenance,
} from "@/lib/time";
const p: TemporalProvenance = {
observedTime: "2026-07-12T14:31:02.000Z",
capturedTime: "2026-07-12T14:31:02.200Z",
ingestedTime: "2026-07-12T14:35:00.000Z",
storedTime: "2026-07-12T14:35:01.000Z",
canonicalStage: "observedTime",
clock: { source: "gps", accuracyMs: 0.00002, model: "utc", synchronized: true },
conversion: { tzdbVersion: "2026a", disambiguation: "none", lossless: true },
};
canonicalInstant(p); // epoch ms of observedTime, or null if monotonic/unmappable
pipelineLatencyMs(p); // storedTime − observedTime = 239_000 ms
provenanceIssues(p); // [] — clean; else out-of-order / monotonic / false-precision
```
The Python parity uses the same lifecycle fields on a dataclass plus
`zoneinfo` for the conversion metadata; the tested reference implementation is the
TypeScript in `lib/time/temporal-provenance.ts`.
## Why it matters now
AI pipelines make this urgent. An [AI-inferred timestamp](/edge-cases/ai-inferred-timestamp/)
is a model output, not an observation; a [synthetic event time](/edge-cases/synthetic-event-time/)
must never masquerade as measured; and when
[multiple clock authorities disagree](/edge-cases/multiple-clock-authorities/)
during an outage, the record must say which one won. None of that fits in a single
ISO-8601 string.
Carrying temporal provenance is what turns this knowledge base from a time-conversion
reference into a temporal-interoperability standard for event streams, distributed
systems, and AI agents.
---
## The 168 Axis
slug: the-168-axis · category: concepts
The canonical unit of this knowledge base is the **hour-of-week slot**: an
integer from 0 to 167 identifying which of the 168 hours in a repeating
week an instant falls in. It is the single coordinate every conversion,
model feature, and report in this KB is ultimately expressed against.
## Definition
Slot 0 is Monday 00:00 UTC. The slot of any UTC instant is computed as:
$$
\text{slot} = (\text{weekdayMon0} \times 24) + \text{hourUTC}, \quad \text{slot} \in [0, 168)
$$
where `weekdayMon0` is 0 for Monday through 6 for Sunday, and `hourUTC` is
the UTC hour-of-day, 0 through 23. The formula takes only the UTC instant
as input — no zone, no local calendar, no declared cutover. That is
deliberate: the slot is defined once, on the one timeline every system in
the world already agrees on, and every zone-aware complexity (which local
hour this corresponds to in Tokyo versus Toronto) is pushed to a separate
annotation layer rather than baked into the coordinate itself.
## A slot repeats; a (week, slot) pair does not
Slot 45 identifies "Wednesday, 21:00 UTC" as a recurring position in the
weekly cycle — this Wednesday, next Wednesday, and every Wednesday since
the epoch share slot 45. That repetition is the entire value of the
coordinate for demand modeling: it lets a Tuesday-lunch spike be compared
week over week without re-deriving "Tuesday lunch" from a calendar each
time. But repetition means slot 45 alone cannot answer "when," only
"which position in the cycle." Uniqueness requires pairing the slot with
an ISO 8601 week number and week-numbering year, producing the canonical
key format `2026-W29-S045` — see
[ISO week and the week-slot key](/docs/iso-week/). The pairing is computed
by `weekSlotKey`, which derives both the ISO week and the slot from the
same UTC instant, so the two halves of the key can never disagree about
which timeline they were measured on.
```ts
import { instantToSlot, slotToLabel } from "@/lib/time/slot";
import { weekSlotKey } from "@/lib/time/isoweek";
const epochMs = Date.UTC(2026, 6, 22, 21, 0, 0); // 2026-07-22T21:00:00Z, a Wednesday
const slot = instantToSlot(epochMs);
// 69 — Wednesday is weekdayMon0=2, hourUTC=21 -> 2*24 + 21 = 69
console.log(slotToLabel(slot));
// "Wed 21:00 UTC" (label is derived from the slot, not recomputed from epochMs)
const key = weekSlotKey(epochMs);
// { isoYear: 2026, isoWeek: 30, slot: 69, key: "2026-W30-S069" }
```
`instantToSlot` and `weekSlotKey` both derive the weekday/hour split from
the same UTC instant, which is the property that matters: two calls
against the same epoch millisecond value, anywhere in the codebase,
always agree, because neither depends on the caller's local clock,
locale, or the platform's default time zone — only on the instant itself.
## The H3-cell analogy
The slot is the time analog of an H3 cell in the geo-interoperability KB:
a deterministic, boundary-independent bucket that every instant (point)
falls into exactly once per period, computed from a fixed, declared
convention rather than from the observer's frame of reference. An H3 cell
does not care which country's coastline drew the polygon it sits inside;
a slot does not care which local clock the observer used to describe the
hour. Both are stable join keys precisely because they are decoupled from
the political and cultural boundaries — administrative or civil-time —
laid over the same underlying continuum. And, just as an H3 cell needs a
resolution parameter to be meaningful (R7 versus R8), a slot needs its
pairing convention (bare slot versus week-keyed slot) declared before
being joined against another dataset — see
[Resolution and grain](/docs/resolution-and-grain/) for when a coarser or
finer unit than the slot is the right choice.
## Edge cases
[Slot-origin convention](/edge-cases/slot-origin-convention/) is the most
common integration bug: a system that assumes a Sunday-start week, or one
that anchors slot 0 to local midnight instead of UTC midnight, disagrees
with this KB's convention by a whole day (24 slots) or by the zone offset,
respectively — and the disagreement is silent until two slot-indexed
datasets are joined and every weekday looks shifted. Always declare the
origin (Monday 00:00 UTC) explicitly when documenting or exporting a
slot-indexed dataset, and convert incoming data from any other convention
on ingest rather than downstream.
[Week-year boundary](/edge-cases/week-year-boundary/) affects the pairing,
not the slot itself: the ISO week-numbering year can differ from the
calendar year in late December and early January (for example,
2027-01-01 falls in ISO week 2026-W53), so a slot must always be carried
alongside both the ISO week number and the ISO week-year, never a bare
week number, or the pairing silently points at the wrong year's week 1.
See [ISO week and the week-slot key](/docs/iso-week/) for the full
resolution.
---
## Time Uncertainty
slug: time-uncertainty · category: provenance
weighted
The geo KB's core discipline is that **a lat/long is a location plus an error
bar**, and a point is assigned to cells at the resolution matched to that error,
never snapped. Time has the exact same structure: a timestamp is an instant plus a
± window, and a slot assignment is only safe when the whole window falls inside one
hour-of-week slot.
Instead of storing
```text
2026-07-12T14:31:02Z
```
store
```text
2026-07-12T14:31:02Z ±200ms
```
This is routine in astronomy, robotics, sensor fusion, autonomous vehicles, and
increasingly in AI pipelines, where a fix carries a stated accuracy.
## The straddle
The canonical slot is a whole UTC hour. When the ± window crosses an hour boundary
the instant has **more than one candidate slot** — the direct temporal analog of a
geo cell straddling a boundary. Snapping to the point estimate throws away the fact
that the true slot is uncertain.
A ±20 ns GPS fix is certain: one slot. A ±200 ms phone fix at 14:59:59.900 is not:
it straddles 15:00 and belongs partly to two slots. Report both candidates and a
confidence — do not pretend to a single answer the clock could not give.
## The model, made checkable
```ts
import {
fromClock,
candidateSlots,
slotIsCertain,
slotConfidence,
} from "@/lib/time";
// A ±200 ms fix at 00:59:59.900 UTC straddles the 00:00 → 01:00 boundary.
const u = { epochMs: Date.UTC(2026, 6, 27, 0, 59, 59, 900), plusMinusMs: 200 };
slotIsCertain(u); // false
candidateSlots(u); // [0, 1] — both hour-of-week slots the window touches
slotConfidence(u); // ~0.5 — fraction of the window in the point-estimate slot
// Build the uncertainty straight from a clock's stated accuracy:
const g = fromClock(Date.UTC(2026, 6, 27, 0, 30), { source: "gps", accuracyMs: 0.00002, model: "utc" });
slotIsCertain(g); // true — one slot
```
The tested reference implementation is `lib/time/uncertainty.ts`. In Python the
same idea is a `(datetime, timedelta)` pair; enumerate the slots at `t - Δ` and
`t + Δ` and every hour boundary between.
## Relationship to grain and false precision
Uncertainty and [resolution/grain](/docs/resolution-and-grain/) are two sides of
one coin: never claim a slot finer than the clock supports. A nanosecond timestamp
from a clock accurate to ±1 second is [false precision](/edge-cases/false-precision/) —
the extra digits are noise that can flip a near-boundary assignment. Carry the
accuracy so downstream code weights, rather than trusts, the point estimate.
For values that are intervals rather than points — a
[one-minute average](/edge-cases/sampling-window/), a scrape window — the same
machinery applies: assign to slots by overlap, weighted, not to a single slot.
---
## Timestamp to Slot
slug: timestamp-to-slot · category: to-canonical
policy_dependent
This is the core conversion of the entire knowledge base: everything else
— ISO week keys, broadcast days, dayparts, MMM features — is built on top
of the slot this page produces. Get it wrong and every downstream
aggregate inherits the error silently.
## Purpose
Convert a local wall-clock timestamp, paired with an IANA time zone
identifier, into the canonical hour-of-week slot (0–167) it falls in. The
conversion is exact arithmetic on a UTC instant, but reaching that instant
requires resolving the local time through the IANA time zone database
first, and that resolution step is where policy — not just arithmetic —
enters.
## Source and destination
Source: `timestamp` (a plain calendar/clock reading — year, month, day,
hour, minute, no offset attached) plus `iana_zone` (a canonical zone id
such as `Asia/Kolkata`, never an abbreviation or a bare offset).
Destination: `hour_of_week_slot`, an integer 0–167.
## Algorithm
```ts
import { localToSlot } from "@/lib/time/slot";
// Ordinary case: no DST transition involved.
const kolkata = localToSlot(
{ year: 2026, month: 1, day: 15, hour: 5, minute: 30 },
"Asia/Kolkata",
"earliest",
);
// kolkata.utc -> "2026-01-15T00:00:00.000Z"
// kolkata.slot -> 72 (2026-01-15 is a Thursday in UTC: weekdayMon0=3,
// hourUTC=0 -> 3*24 + 0 = 72)
// India has held a fixed +5:30 offset since 1945: no gap, no fold.
// Spring-forward gap: 2026-03-08 02:30 in America/New_York never occurs
// (clocks jump 01:59:59 -> 03:00:00). Only one valid instant is adjacent.
const gap = localToSlot(
{ year: 2026, month: 3, day: 8, hour: 2, minute: 30 },
"America/New_York",
"earliest",
);
// gap.utc -> "2026-03-08T07:30:00.000Z"
// gap.wasNonexistent -> true
// gap.disambiguationApplied -> "earliest"
// Fall-back fold: 2026-11-01 01:30 in America/New_York occurs twice, at
// two different UTC offsets an hour apart.
const foldEarliest = localToSlot(
{ year: 2026, month: 11, day: 1, hour: 1, minute: 30 },
"America/New_York",
"earliest",
);
// foldEarliest.utc -> "2026-11-01T05:30:00.000Z" (pre-transition, EDT, -240)
const foldLatest = localToSlot(
{ year: 2026, month: 11, day: 1, hour: 1, minute: 30 },
"America/New_York",
"latest",
);
// foldLatest.utc -> "2026-11-01T06:30:00.000Z" (post-transition, EST, -300)
// Same requested wall time, one UTC hour and typically one slot apart.
// Reject policy: throw rather than silently pick a side.
try {
localToSlot(
{ year: 2026, month: 11, day: 1, hour: 1, minute: 30 },
"America/New_York",
"reject",
);
} catch (e) {
// "Ambiguous local time (fall-back fold): ... occurs twice."
}
```
The Kolkata example is worth double-checking by hand: `2026-01-15 05:30`
in `Asia/Kolkata` (a fixed UTC+5:30 offset, no DST) resolves to
`2026-01-15T00:00:00.000Z`. That instant's UTC weekday is Thursday
(`weekdayMon0 = 3`) at `hourUTC = 0`, giving `slot = 3*24 + 0 = 72`. A
05:30 Kolkata reading only lands on slot 0 when the resolved UTC instant's
*date* happens to be a Monday — the slot depends on the full UTC instant,
not on the local clock reading alone.
## DST and disambiguation behavior
The gap and fold are detected structurally, not by a hardcoded transition
calendar: a gap is any wall time the tz database resolves to a different
clock reading than requested (the time was skipped); a fold is any wall
time for which an hour before and an hour after share the identical wall
clock reading at two different UTC offsets. Both are handled by the same
`disambiguation` parameter — `earliest` (first/pre-transition
occurrence for a fold; the single valid instant for a gap), `latest`
(second/post-transition occurrence for a fold; the same single instant
for a gap, since only one exists), or `reject` (throw for either hazard).
The result always reports `wasNonexistent` and `wasAmbiguous`
independently of which policy was applied, so a caller can distinguish
"resolved cleanly" from "resolved by policy" even when both return a
slot.
Zones like `Asia/Kolkata` (+5:30), `Asia/Kathmandu` (+5:45), and
`Australia/Eucla` (+8:45) sit off the whole UTC hour. A single local clock
hour in these zones straddles two UTC hour-of-week slots — see
`half-hour-offset-zones` — so a local *band* (a daypart, a broadcast
window) in these zones cannot be assigned a single slot the way a single
instant can; it needs the weighted-slot-set treatment covered in
[Resolution and grain](/docs/resolution-and-grain/) and
[Daypart to slots](/docs/daypart-to-slots/). This page's conversion
(a single instant to a single slot) is unaffected: the instant always
lands in exactly one slot regardless of the zone's offset.
## Quality and provenance
Every resolution should be reported with both a `requested` object (the
wall time, zone, and disambiguation policy as given) and an `executed`
object (the resolved slot, UTC instant, offset, and whether a gap or fold
was encountered) — see
[Requested vs. executed time](/docs/requested-vs-executed-time/) for the
full `resolveLocalToCanonical` shape. A resolution where
`wasNonexistent || wasAmbiguous` is true should always be flagged
`lossless: false` downstream: the requested wall time and the executed
instant do not correspond 1:1, even though a single slot was returned.
## Python parity
The tested reference implementation in this KB is the TypeScript above,
backed by luxon. The Python stdlib equivalent uses `zoneinfo` (3.9+) and
`datetime.fold`:
```python
from datetime import datetime
from zoneinfo import ZoneInfo
def local_to_utc(year, month, day, hour, minute, zone_name, fold=0):
tz = ZoneInfo(zone_name)
# fold=0 selects the earlier occurrence of an ambiguous (folded) wall
# time; fold=1 selects the later occurrence. A nonexistent (gap) wall
# time is resolved forward by .astimezone() regardless of fold.
wall = datetime(year, month, day, hour, minute, tzinfo=tz, fold=fold)
return wall.astimezone(ZoneInfo("UTC"))
# Fall-back fold, earlier occurrence (EDT, -04:00):
local_to_utc(2026, 11, 1, 1, 30, "America/New_York", fold=0)
# 2026-11-01 05:30:00+00:00
# Fall-back fold, later occurrence (EST, -05:00):
local_to_utc(2026, 11, 1, 1, 30, "America/New_York", fold=1)
# 2026-11-01 06:30:00+00:00
```
`fold` only disambiguates a repeated wall time; it has no effect on an
ordinary unambiguous timestamp and does not, by itself, resolve a
spring-forward gap — `.astimezone()` always normalizes a nonexistent wall
time forward to the next valid instant, matching this KB's `earliest`
and `latest` policies for the gap case (they coincide, since only one
valid instant exists).
## Edge cases
[Naive datetime, no zone](/edge-cases/naive-datetime-no-zone/) is the most
common upstream failure: a stored `2026-03-08 02:30` with no offset and no
accompanying zone column cannot be resolved at all — and that specific
value does not even exist in `America/New_York`, compounding a missing
zone with a genuine gap. Reject naive timestamps at ingest rather than
assuming UTC or a default zone.
[Ambiguous zone abbreviations](/edge-cases/ambiguous-zone-abbreviations/)
("IST" is India, Ireland, *or* Israel; "CST" is US Central, China, *or*
Cuba) and bare numeric offsets carry no DST rules at all, so they cannot
drive this conversion — require canonical IANA ids and reject
abbreviations on ingest.
[Non-DST region inside a DST country](/edge-cases/non-dst-region-inside-dst-country/)
— Arizona observes no DST while the rest of US Mountain does — means a
country or generic-region label is ambiguous for half the year; resolve
by IANA zone (`America/Phoenix` vs. `America/Denver`), never by country.
[Tzdb-vintage mismatch](/edge-cases/tzdb-vintage-mismatch/) means two
systems on different IANA database releases can resolve the identical
input to different instants near a recently changed transition — pin and
record the tzdb version in provenance, and re-resolve affected wall times
after a database bump.
---
## Timezone Database
slug: timezone-database · category: systems
## Purpose
Every local-to-UTC conversion in this KB — [timestamp-to-slot](/docs/timestamp-to-slot/),
[broadcast day](/docs/broadcast-day/), [daypart-to-slots](/docs/daypart-to-slots/) —
ultimately depends on one shared resource: the **IANA time zone database**
(also called tzdata or the Olson database). It is the versioned record of
every zone's current and historical UTC offset, DST start/end rules, and
transition history. This page states what the database actually is, why "an
offset" and "a zone" are different things, and why the database's own version
number is provenance data that belongs in every conversion's output.
## Zones are Area/Location, not offsets
An IANA zone id has the form `Area/Location` — `America/New_York`,
`Europe/London`, `Asia/Kolkata`, `Pacific/Auckland` — named after a
representative location, not a fixed offset. A zone id encodes a **complete
history** of offsets and DST rules for that location, including every past
change, so that resolving a timestamp from 1985 or a projected timestamp from
2030 both use the correct rules for that instant. This KB's tz engine is
luxon, which reads its rules from the runtime's ICU/tzdata bundle.
## An offset is not a zone
Storing `"UTC+2"` instead of `"Europe/Kyiv"` looks equivalent for a single
instant but is not, because a fixed offset has no DST rule and no future.
`Europe/Kyiv` observed UTC+2 in January and UTC+3 in July prior to Ukraine's
2024 DST discontinuation; a system that persisted `"UTC+2"` at any point
would resolve every subsequent summer timestamp one hour wrong. The rule:
**persist the zone id, derive the offset per instant** — never the reverse,
and never treat an offset as a substitute for a zone in storage.
```ts
import { DateTime } from "luxon";
// WRONG: a fixed offset has no DST rule and silently drifts across a transition.
const bad = DateTime.fromObject({ year: 2026, month: 7, day: 15, hour: 9 }, { zone: "UTC+2" });
// RIGHT: the IANA zone carries the correct offset for whichever date is given.
const good = DateTime.fromObject({ year: 2026, month: 7, day: 15, hour: 9 }, { zone: "Europe/Kyiv" });
```
## Abbreviations are ambiguous
Three-letter zone abbreviations do not uniquely identify a zone: `IST` is
India Standard Time, Irish Standard Time, or Israel Standard Time; `CST` is
US Central, China Standard Time, or Cuba Standard Time; `EST` is used by both
the US and parts of Australia. None of these carry DST rules, and several
collide across completely unrelated regions. This KB's
[timestamp-to-slot](/docs/timestamp-to-slot/) conversion requires a canonical
IANA zone id and rejects bare abbreviations or numeric offsets at the input
boundary rather than guessing.
## Windows zone ids are a different vocabulary
Windows identifies zones by display name — `"Eastern Standard Time"`,
`"Pacific Standard Time"` — which do not match IANA ids 1:1 and, confusingly,
Windows' `"Eastern Standard Time"` actually covers the same DST-observing
region as IANA's `America/New_York`, not literally standard-time-only.
Translating between the two vocabularies requires the CLDR `windowsZones`
mapping table (a many-to-one map, since several IANA zones can share one
Windows display name); never attempt a string-similarity guess between them.
## The database changes — pin and record the version
The tz database is not static: the IANA maintainers cut a new release roughly
ten times a year, almost always in response to a government changing an
offset, DST rule, or zone boundary with real-world effective dates
(historically: Lebanon's abrupt 2023 DST delay, Ramadan-linked DST pauses in
Egypt and Morocco, Chile and Fiji adjusting DST windows). A political change
frequently arrives with only days of public notice, so there is always a
window where the deployed tzdb has not yet caught up to reality — an
unavoidable lag, not a bug, but one that must be surfaced rather than hidden.
Two systems pinned to **different tzdb releases** can resolve the identical
(wall time, zone) pair to two different UTC instants near any changed
transition — the direct temporal analog of a stale administrative-boundary
vintage in the geo KB. The mitigation is the same pattern used throughout
this KB's provenance model: record the tzdb version actually used
(`ConversionProvenance.tzdbVersion` in `lib/time/provenance.ts`) on every
conversion, and re-resolve any wall time near a known transition once the
runtime's tzdb is bumped.
## Historical offsets are not today's offset
Zones changed their base offset long before modern DST existed, and some
still do: Samoa moved its date-line side in 2011, skipping December 30
entirely to switch from UTC-11 to UTC+13; Venezuela shifted by 30 minutes in
2007 and reverted in 2016; North Korea briefly ran 30 minutes off its
neighbors from 2015-2018. A conversion for a historical instant must use the
offset that was actually in force **then**, not the zone's current offset —
luxon (via the full tzdata history) resolves this correctly by construction
as long as the zone id, not a cached offset, is what was stored.
## Edge cases
[Tzdb vintage mismatch](/edge-cases/tzdb-vintage-mismatch/),
[political change with short notice](/edge-cases/political-change-short-notice/),
[ambiguous zone abbreviations](/edge-cases/ambiguous-zone-abbreviations/),
[offset is not a zone](/edge-cases/offset-is-not-a-zone/),
[Windows vs IANA ids](/edge-cases/windows-vs-iana-ids/), and
[historical offset changes](/edge-cases/historical-offset-changes/) are all
direct instances of the hazards above; see
[Timestamp to Slot](/docs/timestamp-to-slot/) for how they surface in the
core local-to-UTC conversion, and [DST Handling](/docs/dst-handling/) for the
gap/fold behavior the database's DST rules produce.
## Python parity
Python's standard library `zoneinfo` module (3.9+) reads the same IANA tz
database — either from the operating system's copy or, if absent, from the
`tzdata` PyPI package — so a correctly configured Python runtime resolves
zone ids identically to luxon, provided both are running the same tzdb
release. Checking that release:
```python
import zoneinfo
print(zoneinfo.TZPATH) # where the OS/package tzdata is being read from
```
There is no cross-language guarantee of matching tzdb versions without
explicit alignment — pin `tzdata` to the same release the Node/luxon runtime
uses if exact cross-system agreement near a recent transition matters.
---
## Week Systems
slug: week-systems · category: systems
## Purpose
"Week 29" means at least four different things depending on which week
system produced the number. This page catalogs the competing conventions
this KB has to interoperate with, states which one it standardizes on, and
gives the crosswalk logic for translating between them.
## The competing systems
A single instant can therefore carry a different week number under each
system, and even systems that agree on the start day (ISO and broadcast both
start Monday) can still disagree on which week is "week 1" of the year,
because their year boundaries and roll-up rules differ.
## This KB's standard: ISO for the canonical key
This knowledge base standardizes on **ISO-8601** for the `(isoYear, isoWeek,
slot)` key used everywhere — see [ISO Week](/docs/iso-week/) and
[The 168 Axis](/docs/the-168-axis/). ISO was chosen because it is
parameter-free and exact (no declared cutover, no fiscal-year anchor to
configure) and because its Monday start aligns naturally with slot 0 =
Monday 00:00 UTC. Any other week system a downstream platform reports in
(US/retail, broadcast) is treated as a **presentation crosswalk** applied on
top of the canonical ISO key, not as an alternate canonical grain.
## Crosswalk logic
```ts
import { isoWeekOf } from "@/lib/time/isoweek";
import { DateTime } from "luxon";
// ISO week (this KB's canonical): Monday start.
const iso = isoWeekOf(Date.parse("2026-07-19T00:00:00Z")); // a Sunday
// -> { isoYear: 2026, isoWeek: 29, isoWeekday: 7 }
// US/retail week number (Sunday start) for the SAME instant requires a
// different anchor rule entirely — it is not a fixed offset from the ISO
// week, because the two systems' "week 1" definitions diverge independently
// each year. A retail crosswalk must be computed against the retailer's own
// declared fiscal calendar, not derived from the ISO week number.
const dt = DateTime.fromMillis(Date.parse("2026-07-19T00:00:00Z"), { zone: "utc" });
const sundayStartWeekday = dt.weekday % 7; // 0 = Sunday ... 6 = Saturday
```
The critical point the snippet makes explicit: because ISO and US/retail
weeks can start their **year** in different places (ISO week 1 anchors to
the first Thursday; a retail fiscal year anchors to a declared date near
month-end), there is no universal arithmetic formula converting an ISO week
number directly into a retail week number — the retailer's specific fiscal
calendar (its declared year-start date) must be consulted. The only safe
general crosswalk is instant-based: resolve the target instant, then apply
each system's own rule to that instant independently, rather than
transforming one week number into another.
## Why a bare week number is a bug
A payload containing only `"week": 29` cannot be interpreted correctly by
any receiving system without also knowing which week system produced it —
ISO week 29 of 2026 (Jul 13-19), a US/retail week 29 (a different date
range, anchored to that retailer's fiscal year start), and a broadcast week
29 (aligned to Nielsen's broadcast calendar) are three different seven-day
spans that happen to share a number. Always pair a week number with its
system, and prefer the full `(isoYear, isoWeek)` pair — or the combined
`week_slot_key` — as the canonical join key.
## Edge cases
[Week-numbering systems](/edge-cases/week-numbering-systems/) is this page's
core subject. [Broadcast calendar month](/edge-cases/broadcast-calendar-month/):
because a broadcast month is a whole number of broadcast weeks rather than a
Gregorian month, reconciling broadcast-month reporting against calendar-month
reporting requires a declared week-to-month crosswalk, not a date-range
assumption — see [Broadcast Day](/docs/broadcast-day/) for the related
per-day convention. [Week-year boundary](/edge-cases/week-year-boundary/) and
[fifty-three-week years](/edge-cases/fifty-three-week-years/) are properties
of the ISO system specifically and are detailed on the
[ISO Week](/docs/iso-week/) page.
## Python parity
```python
from datetime import datetime, timezone
def iso_week(epoch_ms: int) -> tuple[int, int, int]:
return datetime.fromtimestamp(epoch_ms / 1000, tz=timezone.utc).isocalendar()
def sunday_start_weekday(epoch_ms: int) -> int:
# 0 = Sunday ... 6 = Saturday, for building a US/retail-style crosswalk.
dt = datetime.fromtimestamp(epoch_ms / 1000, tz=timezone.utc)
return (dt.isoweekday()) % 7
```
Python's `isocalendar()` gives the ISO figures natively; a US/retail or
broadcast crosswalk still requires each system's own declared calendar (a
retailer's 4-5-4 fiscal year, or Nielsen's broadcast calendar) as external
input — no standard library or package encodes those rules generically,
since they are business conventions, not international standards.
---
## Edge case: Half-hour offset zones
id: half-hour-offset-zones · category: offset
India (+5:30), Sri Lanka (+5:30), central Australia ACST (+9:30), Iran (+3:30), Afghanistan (+4:30), Myanmar (+6:30), and Newfoundland (-3:30) sit a half hour off UTC. A local clock hour therefore straddles TWO UTC hour-of-week slots, so a local band (daypart, broadcast day) never maps to a whole number of slots.
Detection: zoneOffsetMinutes % 60 !== 0.
Mitigation:
- Keep the canonical slot on the UTC integer hour; do not re-base it to local.
- Express local bands as a WEIGHTED slot-set — the fraction of each hour that falls in each UTC slot (the temporal analog of the geo weighted crosswalk), or assign by majority overlap and declare which.
Example: 05:30 Asia/Kolkata = 00:00 UTC = slot 0; 06:00 IST = 00:30 UTC, which is still slot 0 but only half-covers it.
---
## Edge case: 45-minute offset zones
id: forty-five-minute-offset-zones · category: offset
Nepal (+5:45), the Chatham Islands (+12:45 / +13:45 DST), and the Australian Eucla region (+8:45) are offset by three quarters of an hour. A local hour straddles a UTC slot 45/15, so even majority assignment is badly lopsided.
Detection: zoneOffsetMinutes % 60 === 45 (or === 15).
Mitigation:
- Overlap-weighted apportionment only; never snap a 45-minute-straddled hour to a single slot.
- Carry the fractional weights through to reporting so the bias is visible.
Example:
---
## Edge case: Sub-hour band straddle (general)
id: sub-hour-band-straddle · category: offset
Because the canonical unit is a whole UTC hour, ANY local band whose edges do not land on a UTC hour boundary produces fractional slot membership — from sub-hour offsets, from bands defined at :30 (e.g. daytime 09:30–16:30), or from DST. This is the same phenomenon as a geo cell straddling a boundary.
Detection: (localBandEdgeMinute + zoneOffsetMinutes) % 60 !== 0.
Mitigation:
- Model the band as a weighted slot-set (fraction of the hour in each slot).
- Pick one rule — overlap-weighted, majority, or edge-inclusive — and record it.
Example:
---
## Edge case: Spring-forward gap (nonexistent local time)
id: spring-forward-gap · category: dst
When clocks jump forward, a local wall time never occurs (e.g. 02:30 on 2026-03-08 in America/New_York). Resolving it to a slot requires a declared policy, not a silent guess.
Detection: The zone resolves the requested wall time to a different clock time (it was skipped).
Mitigation:
- Declare a policy: shift-forward (post-gap instant), shift-back, or reject.
- Record wasNonexistent on the result (requested vs executed).
Example:
---
## Edge case: Fall-back fold (ambiguous local time)
id: fall-back-fold · category: dst
When clocks fall back, a local wall time occurs twice (e.g. 01:30 on 2026-11-01 in America/New_York), once at the pre-transition offset and once at the post-transition offset — two different UTC instants, two different slots.
Detection: Two instants one hour apart share the same wall time with different offsets.
Mitigation:
- Declare disambiguation: earliest (first occurrence), latest (second), or reject.
- Record wasAmbiguous and which policy was applied.
Example:
---
## Edge case: Partial-hour DST shift
id: partial-hour-dst-shift · category: dst
Not every DST change is one hour. Lord Howe Island shifts by 30 minutes (+10:30 ↔ +11:00); some historical transitions were 20 or 40 minutes. The gap or fold is then a partial hour, so the ambiguity window is not a whole slot.
Detection: offsetDelta across the transition !== 60 minutes.
Mitigation:
- Compute gap/fold magnitude from the tz database; never assume a one-hour shift.
- A 30-minute fold means only half the hour is ambiguous — apportion accordingly.
Example:
---
## Edge case: DST transition time and date vary by zone
id: dst-transition-time-varies · category: dst
The '02:00' spring-forward is US-centric. Other zones transition at 00:00, 01:00, 03:00, or 23:00 local, on different dates, and the Southern Hemisphere transitions in the opposite calendar months. Hardcoding a transition time or date is wrong outside one region.
Detection: Transition instants read from the tz database differ from any assumed constant.
Mitigation:
- Always resolve through the IANA tz database (luxon); never hardcode 02:00 or a fixed date.
Example:
---
## Edge case: Reversed (Southern Hemisphere) DST
id: southern-hemisphere-reversed-dst · category: dst
DST runs in the opposite calendar months south of the equator, and transition dates differ per country within the same offset. The same UTC slot is a different local season and local hour band in Sydney vs New York.
Detection: Zone DST calendar / hemisphere.
Mitigation:
- Never compare local semantics (primetime, morning) across hemispheres by UTC slot alone.
- Carry a local annotation alongside the UTC slot.
Example:
---
## Edge case: Non-DST region inside a DST country
id: non-dst-region-inside-dst-country · category: dst
Arizona observes no DST while the rest of US Mountain does; Queensland differs from New South Wales. A country name or a 'Mountain Time' label is ambiguous for half the year.
Detection: DST rules resolved at zone level disagree with a country/offset label.
Mitigation:
- Resolve by IANA zone (America/Phoenix vs America/Denver), never by country or offset.
Example:
---
## Edge case: Timezone-database vintage mismatch
id: tzdb-vintage-mismatch · category: tzdb
The IANA tz database is released roughly ten times a year for political changes. Two systems on different releases resolve the same (wall time, zone) to different UTC instants near a changed transition — the temporal analog of a boundary-vintage mismatch in geo.
Detection: Compare the tzdb version recorded in each system's provenance.
Mitigation:
- Pin and RECORD the tzdb version on every conversion (requested vs executed).
- Re-resolve affected wall times when the tzdb is bumped.
Example:
---
## Edge case: Political time change with short notice
id: political-change-short-notice · category: tzdb
Governments change offsets or DST with days of notice — Lebanon (2023), Egypt and Morocco (Ramadan DST), Samoa, Venezuela. The tzdb lags real life, so systems disagree for a window until they update.
Detection: A recent announced change postdates the deployed tzdb release.
Mitigation:
- Track tzdb releases; expect and flag transient disagreement around announced changes.
- Surface the tzdb version so a stale conversion is diagnosable.
Example:
---
## Edge case: Ambiguous zone abbreviations and bare offsets
id: ambiguous-zone-abbreviations · category: tzdb
'IST' means India, Ireland, OR Israel; 'CST' means US Central, China, OR Cuba; 'EST' is used by the US and Australia. Three-letter abbreviations and bare offsets cannot identify a zone or its DST rules.
Detection: Input is an abbreviation or a numeric offset rather than an IANA zone id.
Mitigation:
- Require canonical IANA zone ids (Area/Location). Reject abbreviations.
Example:
---
## Edge case: An offset is not a zone
id: offset-is-not-a-zone · category: tzdb
Storing 'UTC+2' (a fixed offset) instead of 'Europe/Kyiv' (a zone) loses the DST rules, so any FUTURE wall time in that zone resolves wrong. An offset is only valid for one instant.
Detection: The stored zone value is a fixed offset, not a named zone.
Mitigation:
- Persist the IANA zone id; derive the offset per instant, never the reverse.
Example:
---
## Edge case: Windows vs IANA zone ids
id: windows-vs-iana-ids · category: tzdb
Windows uses display names like 'Eastern Standard Time' where IANA uses 'America/New_York'; the mapping is many-to-one and requires the CLDR windowsZones table.
Detection: Zone id is a Windows display name, not an IANA id.
Mitigation:
- Normalize through CLDR windowsZones before resolving.
Example:
---
## Edge case: Historical offset changes
id: historical-offset-changes · category: tzdb
Before standardization zones ran on local mean time, and countries have changed their base offset since (Samoa skipped 2011-12-30 crossing the date line; Venezuela and North Korea shifted by 30 minutes). Old timestamps need the historical offset, not today's.
Detection: Instant predates a zone's offset change.
Mitigation:
- Use the full tz history (luxon/IANA carry it); never assume the current offset for a past instant.
Example:
---
## Edge case: Competing week-numbering systems
id: week-numbering-systems · category: week
ISO weeks start Monday; US/retail weeks often start Sunday; broadcast (Nielsen) weeks start Monday but sit inside a different month calendar; some Middle-Eastern weeks start Saturday or Sunday. 'Week 29' is ambiguous without the system.
Detection: A week number arrives without its numbering system declared.
Mitigation:
- Declare the week system; default to ISO-8601; provide crosswalks to US/broadcast.
Example:
---
## Edge case: Week-year ≠ calendar year
id: week-year-boundary · category: week
The ISO week-numbering year can differ from the calendar year around January 1 — 2027-01-01 belongs to 2026-W53, and 2026-12-31 can fall in the next year's W01. Carrying a week number without its week-year is a bug.
Detection: Date is in late December or early January and the week is 52/53/01.
Mitigation:
- Always carry (isoYear, isoWeek) as a pair; never a bare week number.
Example:
---
## Edge case: 53-week years
id: fifty-three-week-years · category: week
Some ISO years have 53 weeks (when Jan 1 is Thursday, or a leap year starts on Wednesday — e.g. 2026). Code that assumes 52 weeks misaligns year-over-year comparisons and drops a week.
Detection: weeksInWeekYear === 53.
Mitigation:
- Handle 53-week years explicitly; align year-over-year by week-year, not a fixed 52-offset.
Example:
---
## Edge case: Broadcast (Nielsen) calendar month and quarter
id: broadcast-calendar-month · category: week
The broadcast month is a whole number of broadcast weeks and does not align to the calendar month; a broadcast quarter has 13 or 14 weeks; the broadcast year boundary differs from the calendar. Rollups reconcile only within one calendar.
Detection: Reporting mixes broadcast-month and calendar-month grains.
Mitigation:
- Declare which calendar; provide a broadcast↔calendar week crosswalk.
Example:
---
## Edge case: Slot-origin convention
id: slot-origin-convention · category: week
The canonical origin is slot 0 = Monday 00:00 UTC. A system that assumes a Sunday-start week is off by 24 slots; one that anchors on local midnight disagrees with the UTC origin by the zone offset.
Detection: Slot indices disagree by a whole day (24) or by the zone offset.
Mitigation:
- Declare the origin (Monday 00:00 UTC) and convert other conventions on ingest.
Example:
---
## Edge case: Broadcast-day cutover is not universal
id: broadcast-day-cutover-varies · category: week
The broadcast day does not always start at 06:00 local — some operations use 05:00, 02:00, or midnight, and sports/overnight feeds differ. An undeclared cutover silently reassigns early-morning events to the wrong day.
Detection: The cutover hour is not declared on the request.
Mitigation:
- Declare the cutover and echo it on every result (requested vs executed).
Example:
---
## Edge case: Naive datetime with no zone
id: naive-datetime-no-zone · category: data_quality
A stored '2026-03-08 02:30' with no offset and no accompanying zone cannot be resolved to a slot — and, unluckily, that particular value does not even exist in US Eastern. Zone-less local timestamps are the most common warehouse hazard.
Detection: Timestamp string carries no offset and no zone column travels with it.
Mitigation:
- Require an accompanying IANA zone; reject naive timestamps rather than assuming UTC.
Example:
---
## Edge case: Local timestamps read as UTC (or vice versa)
id: local-read-as-utc · category: data_quality
A column stored in local time is read as UTC, or a UTC column is read as local. Every event shifts by the offset, biasing every slot systematically — the diurnal curve peaks at the wrong hour.
Detection: The aggregate diurnal shape is shifted by a constant offset from the expected shape.
Mitigation:
- Pin each timestamp column's zone in the schema; validate the diurnal shape after ingest.
Example:
---
## Edge case: Epoch and unit confusion
id: epoch-and-unit-confusion · category: instant
Seconds vs milliseconds vs microseconds, and non-Unix epochs (NTP 1900, Apple 2001, Windows FILETIME 1601), place events off by 1000× or in the wrong century — a whole dataset lands in one slot or in 1970.
Detection: Implausible resolved year (1970 clustering, 1601, or far future).
Mitigation:
- Assert the unit and epoch on ingest; range-check to a plausible window.
Example:
---
## Edge case: Clock skew and sentinel timestamps
id: clock-skew-and-sentinels · category: data_quality
Device clocks are wrong; bidstream timestamps can be in the future or negative; missing values default to 1970-01-01 (the temporal 'zero-island'); rounded values pile up at 00:00:00. These are not real slots.
Detection: Timestamps at epoch 0, in the future, or spiking at midnight.
Mitigation:
- Bound-check and flag or drop sentinels; never bucket 1970 or a future time as a real slot.
Example:
---
## Edge case: Timestamp rounding / truncation
id: timestamp-rounding-truncation · category: data_quality
Timestamps truncated to the day or hour for storage or privacy lose hour-of-week signal; a date-only value cannot be placed in a slot at all.
Detection: Zero variance below the day or hour grain.
Mitigation:
- Cap the claimed grain to the truncation; do not report finer than the data supports.
Example:
---
## Edge case: Leap seconds
id: leap-seconds · category: instant
UTC has inserted 27 leap seconds since 1972, so a day is occasionally 86,401 seconds; Google and AWS 'smear' the leap second over 24 hours, disagreeing with UTC by up to ~0.5 s. Negligible for hour-of-week bucketing, but real for high-precision joins. (Leap seconds are being phased out by ~2035.)
Detection: Sub-second precision required near a leap-second insertion.
Mitigation:
- State the clock model (UTC vs TAI vs smeared). For hour-of-week the error is sub-second — declare it negligible rather than silent.
Example:
---
## Edge case: Date-line weekday divergence
id: date-line-weekday-divergence · category: dateline
Across the International Date Line the same UTC instant is a different local weekday. Kiribati (+13/+14) and Samoa (+13) are a full day ahead of the Americas, so an event's local weekday can differ from the weekday of its UTC slot — the temporal analog of the geo antimeridian.
Detection: |zone offset| approaches or exceeds 12 hours.
Mitigation:
- Keep the canonical slot on UTC; annotate the local weekday separately; expect local-week ≠ UTC-week near the line.
Example:
---
## Edge case: Extreme offset span (UTC−12 … +14)
id: extreme-offset-span · category: dateline
The inhabited world spans 26 hours of offset, so a single local hour exists across a more-than-24-hour span of UTC instants. A local daypart aggregated across many zones can therefore occupy a UTC slot-set that wraps the entire week.
Detection: Aggregation spans zones whose offsets differ by more than 24 hours.
Mitigation:
- Keep per-zone slot-sets; union only in UTC, and expect week-wrap.
Example:
---
## Edge case: UTC canonical vs local experience
id: utc-canonical-vs-local-experience · category: semantics
The unit is UTC, but 'primetime', 'morning', and 'lunch' are LOCAL experiences (8pm local everywhere). Comparing behavior at the same UTC slot is not the same as at the same local hour — the core temporal-interop tension, and the direct analog of geo's requested-vs-executed geography.
Detection: A cross-zone comparison holds the UTC slot fixed while the intent is a local hour (or vice versa).
Mitigation:
- Store the canonical UTC slot plus a declared local annotation; compare local experience via local slots, join in UTC.
Example:
---
## Edge case: Event vs ingestion vs report time
id: event-vs-ingestion-vs-report-time · category: semantics
When something HAPPENED, when it was LOGGED, and when it is ATTRIBUTED are three different instants that fall in three different slots. Reports silently mix them.
Detection: A record carries multiple time columns and the slot-defining one is not declared.
Mitigation:
- Declare which time defines the slot (default: event time); carry the others explicitly.
Example:
---
## Edge case: Attribution-window time
id: attribution-window-time · category: measurement
A conversion is credited to an earlier impression, so the 'slot' of a conversion depends on the attribution model — the conversion's own time, or the attributed impression's time.
Detection: A conversion's timestamp differs from the impression it is credited to.
Mitigation:
- State whether the slot is the event time or the attributed time; keep both.
Example:
---
## Edge case: No silent temporal rollup
id: no-silent-temporal-rollup · category: measurement
A buyer requests hourly (slot) delivery or reporting and the platform reports at day or week grain. The temporal analog of geo's no-silent-rollup rule: coarsening the time grain is the buyer's operation, not a quiet default.
Detection: Report grain is coarser than the requested grain.
Mitigation:
- Report at the requested grain or reject the request; never silently roll up hours into a day.
Example:
---
## Edge case: Slot-boundary dedup and double-count
id: slot-boundary-dedup · category: measurement
An event, session, or airing that spans a slot boundary can be counted in two slots or dropped — the temporal analog of geo's touching-only / duplicate eligibility.
Detection: A session or airing duration crosses a :00 boundary.
Mitigation:
- Declare the assignment rule (start-time, end-time, or overlap-weighted) and dedup by (key, slot).
Example:
---
## Edge case: Timezone inferred from geography
id: inferred-timezone-from-geo · category: data_quality
Bidstream and sensor data often lack a reliable device timezone, so it is inferred from a lat/long via a timezone-boundary shapefile — a GEO × TIME crosswalk. A wrong zone shifts the local hour and the daypart. A lat/long is a location plus an error bar, and so is the tz lookup it feeds.
Detection: The zone is derived from coordinates rather than declared.
Mitigation:
- Record the tz source and confidence; cap daypart precision to the lookup's reliability, exactly as the geo KB caps a point→cell assignment to its accuracy radius.
Example:
---
## Edge case: Substitute (in-lieu) holiday days
id: substitute-day-holidays · category: week
When a public holiday falls on a weekend, many countries observe a substitute weekday instead (UK 'bank holiday in lieu', much of APAC). The observed date is not the calendar date, so a naive fixed-date rule flags the wrong day.
Detection: A fixed-date holiday lands on Sat/Sun and the country grants an in-lieu day.
Mitigation:
- Apply each country's substitution rule; carry both the nominal and observed dates.
Example:
---
## Edge case: Movable and regional holidays
id: movable-and-regional-holidays · category: week
Easter-derived dates move each year (computus), lunar-calendar holidays (Eid, Diwali, Lunar New Year) shift against the Gregorian calendar, and many holidays are regional (US state, Canadian province, German Land). A single national list is incomplete.
Detection: Holiday is lunar/movable, or observed only in a subnational region.
Mitigation:
- Compute movable dates (Easter computus, lunar tables); key holidays by region, not just country.
Example:
---
## Edge case: Monotonic vs wall clock
id: monotonic-vs-wall-clock · category: clock
A monotonic clock (performance.now(), CLOCK_MONOTONIC) measures elapsed time from an arbitrary origin and has no fixed epoch, so it cannot be converted to UTC or a slot. Mixing monotonic and wall-clock timestamps silently corrupts latency analysis.
Detection: Clock model is monotonic, or a duration and an absolute time are stored in the same column.
Mitigation:
- Never map a monotonic reading to UTC; keep it as a duration relative to a wall-clock anchor captured once.
Example:
---
## Edge case: Timestamp provenance (which time became canonical)
id: timestamp-provenance · category: clock
Sensor time, API time, database time, and client time are different instants. Which one became the canonical event time (and therefore the slot) is a modeling decision that must travel with the value, exactly like geometry provenance in the geo KB.
Detection: A record carries several time columns and the canonical stage is not declared.
Mitigation:
- Carry the full lifecycle (observed/captured/received/ingested/stored/reported) and name the canonical stage.
Example:
---
## Edge case: Clock-accuracy metadata
id: clock-accuracy-metadata · category: clock
A GPS fix (~20 ns), a phone clock (~100 ms), and a server clock (~5 ms) are not interchangeable. Treating every timestamp as equally precise hides which slot assignments are safe and which are near a boundary.
Detection: Timestamps arrive without a clock source or accuracy field.
Mitigation:
- Attach clock source, accuracy, and model; carry them into the uncertainty of the slot assignment.
Example:
---
## Edge case: Timestamp confidence interval
id: timestamp-confidence-interval · category: clock
An instant is a point estimate plus an error bar — 12:03:10 ±150 ms, not a single moment. Near an hour boundary the ±window straddles two slots, so the assignment is not unique (the temporal weighted crosswalk).
Detection: The ±window around the estimate crosses a slot boundary.
Mitigation:
- Represent the instant as (epoch ± ms); enumerate candidate slots and report a confidence, do not snap.
Example:
---
## Edge case: NTP synchronization state
id: ntp-sync-state · category: clock
A device whose clock is unsynchronized can be minutes off while still emitting well-formed timestamps. The format is valid; the value is not.
Detection: Sync flag is false/unknown, or timestamps drift against a trusted reference.
Mitigation:
- Record synchronization state; widen uncertainty or quarantine data from unsynced clocks.
Example:
---
## Edge case: VM snapshot rollback
id: vm-snapshot-rollback · category: clock
Restoring a virtual machine from a snapshot moves its clock backwards, so a later event can carry an earlier timestamp than an earlier one.
Detection: Monotonically issued events show a backwards timestamp step.
Mitigation:
- Detect non-monotonic lifecycle steps; prefer a server-side receive time or a logical clock for ordering.
Example:
---
## Edge case: Container / live migration
id: container-migration · category: clock
A container or VM moved between hosts inherits a different clock quality and synchronization history, so timestamp reliability changes mid-stream without any application signal.
Detection: Host or clock-source identity changes across a stream.
Mitigation:
- Stamp the clock source/host with each batch; re-baseline uncertainty on migration.
Example:
---
## Edge case: Offline capture, delayed replay
id: offline-replay · category: clock
IoT and mobile devices capture events offline and upload them later, so yesterday's events arrive today. Arrival order is not occurrence order, and late data reopens already-reported slots.
Detection: Received time lags captured time by hours or days.
Mitigation:
- Slot by captured/observed time, not receive time; treat reporting slots as revisable (watermarks / late-arrival windows).
Example:
---
## Edge case: Clock-correction jumps
id: clock-correction-jumps · category: clock
After synchronization, NTP can step a clock several seconds backwards (rather than slewing), so consecutive events straddle a discontinuity and can invert in order.
Detection: A sudden multi-second backwards (or forwards) jump in an otherwise smooth series.
Mitigation:
- Prefer slewed clocks; for ordering use a monotonic source or a hybrid logical clock.
Example:
---
## Edge case: Multiple clock authorities disagree
id: multiple-clock-authorities · category: clock
GPS, PTP, and NTP can disagree during outages or holdover. Which authority wins determines the timestamp, and the choice is often undocumented.
Detection: More than one time source is available and they diverge.
Mitigation:
- Declare an authority priority; record which source produced each value and its holdover state.
Example:
---
## Edge case: AI-inferred timestamp
id: ai-inferred-timestamp · category: ai
A timestamp reconstructed by an LLM or by interpolation is a model output, not an observation. Treating an inferred time as measured injects unquantified error into every slot it lands in.
Detection: Clock source is 'inferred'; the timestamp has no upstream sensor provenance.
Mitigation:
- Mark inferred times distinctly; attach an uncertainty and never merge them with measured times without a flag.
Example:
---
## Edge case: Synthetic event time
id: synthetic-event-time · category: ai
Simulated or generated data carries fabricated timestamps that must never masquerade as measured time, or backtests and training sets silently mix real and synthetic history.
Detection: Clock source is 'synthetic'; provenance points to a generator, not a sensor.
Mitigation:
- Tag synthetic time at the source and preserve the tag through every join and rollup.
Example:
---
## Edge case: Embedding validity time
id: embedding-validity-time · category: ai
An embedding or feature computed months ago may no longer represent today's semantics. The datum has two times — when the event happened and when the representation was valid — and joining on the wrong one drifts the model.
Detection: A feature/embedding lacks a 'valid-as-of' time distinct from the event time.
Mitigation:
- Store the representation's valid-as-of time alongside the event time; expire or recompute stale embeddings.
Example:
---
## Edge case: Model training window (temporal leakage)
id: model-training-window · category: ai
A model trained on data that would not have been available at prediction time leaks the future into the past. The slot a datum belongs to is not the slot at which it was knowable.
Detection: Training features include values timestamped after the prediction's as-of time.
Mitigation:
- Enforce an as-of cutoff (point-in-time correctness); only use data whose knowable-time precedes the prediction.
Example:
---
## Edge case: Prediction time vs observation time
id: prediction-vs-observation-time · category: ai
A forecast generated on Monday for Friday has two times — when it was made and what it is about. Storing only one makes the forecast unauditable and mixes horizons.
Detection: A forecast row carries a single timestamp.
Mitigation:
- Store both the issued-at time and the target time; slot and compare on the target, audit on the issued-at.
Example:
---
## Edge case: Partial ordering only
id: partial-ordering-only · category: distributed
Two events on different nodes cannot always be globally ordered — there may be no fact of the matter about which came first. A UTC total order imposed on them is an artifact of clock skew, not causality.
Detection: Events originate on independent nodes with no happens-before relation.
Mitigation:
- Accept a partial order; use vector clocks to mark concurrent events rather than forcing a UTC tiebreak.
Example:
---
## Edge case: Lamport / vector clocks (causal ≠ UTC order)
id: lamport-vector-clocks · category: distributed
Causal order and UTC order are different relations. A can happen-before B while A's UTC timestamp is later, because of queue delay, retry, or skew. Event sourcing and multi-agent systems need the causal order.
Detection: Downstream logic depends on ordering across nodes/agents.
Mitigation:
- Carry a Lamport or vector timestamp (or a hybrid logical clock) for ordering; keep UTC for wall-clock reporting.
Example:
---
## Edge case: Message-queue delay and reordering
id: message-queue-delay · category: distributed
Kafka, SQS, and similar queues delay and reorder delivery, so arrival order at a consumer is not production order. Slotting by arrival time misplaces events.
Detection: Consumer arrival order disagrees with producer event time.
Mitigation:
- Slot by the producer's event time carried in the payload, not the consumer's receive time; handle out-of-order with watermarks.
Example:
---
## Edge case: Duplicate event replay
id: duplicate-event-replay · category: distributed
At-least-once delivery replays the same event, with an identical event timestamp but a new ingestion time. Naive counting double-counts the slot.
Detection: Repeated (idempotency key, event time) with differing ingestion times.
Mitigation:
- Deduplicate by idempotency key + event time before aggregating into a slot.
Example:
---
## Edge case: Event versioning and corrections
id: event-versioning · category: distributed
An event is corrected later while its original occurrence time is preserved. The correction has a new processing time but the same event time, so the slot stays put while the value changes.
Detection: A later record supersedes an earlier one for the same event key.
Mitigation:
- Keep occurrence time fixed and version by processing time; recompute the affected slot as-of the latest version.
Example:
---
## Edge case: False precision
id: false-precision · category: precision
A timestamp stored to nanoseconds from a clock accurate only to ±1 second implies precision the source never had. The extra digits are noise that can flip a near-boundary slot assignment.
Detection: Stored precision (digits) exceeds the clock's stated accuracy.
Mitigation:
- Round to the clock's real resolution; carry accuracy so downstream code does not over-trust the digits.
Example:
---
## Edge case: Mixed-precision dataset
id: mixed-precision-dataset · category: precision
Some rows carry seconds, others milliseconds, others nanoseconds. A single numeric parse then places rows off by factors of 1000, scattering them across the wrong slots or epochs.
Detection: Timestamp magnitudes cluster at 10-, 13-, or 16-digit lengths within one column.
Mitigation:
- Detect and normalize the unit per row before conversion; assert a plausible resulting year.
Example:
---
## Edge case: Averaged timestamp
id: averaged-timestamp · category: precision
One timestamp that summarizes thousands of observations (a mean or a bucket label) is not an instant; the observations it stands for span a range and may cross slot boundaries.
Detection: A single time represents an aggregate of many rows.
Mitigation:
- Carry the aggregation window (start, end) alongside the summary time; slot the window, not the mean.
Example:
---
## Edge case: Sampling window vs instant
id: sampling-window · category: precision
A timestamp can denote a measurement INTERVAL rather than a moment — a one-minute average, a five-minute scrape. Treating the interval as an instant drops the fact that it may span multiple slots.
Detection: The value is a periodic sample or window aggregate.
Mitigation:
- Model the value as an interval [start, end); assign to slots by overlap (weighted), not to a single slot.
Example:
---
## Edge case: Interval center vs start vs end
id: interval-center-vs-start · category: precision
Is a 10:00 reading the beginning, midpoint, or end of a one-hour measurement? The convention shifts every value by up to an hour and is rarely recorded.
Detection: Interval-labelled data without a stated label position (start/center/end).
Mitigation:
- Declare the label position; convert to a canonical (usually interval-start) before slotting.
Example:
---