# 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: ---