{
 "docs": [
  {
   "title": "Broadcast Day",
   "slug": "broadcast-day",
   "category": "calendar",
   "summary": "A broadcast day is a local day that starts at a declared cutover, not at midnight, so hours before the cutover belong to the previous broadcast day.",
   "source_geometry": [
    "instant",
    "iana_zone"
   ],
   "destination_geometry": [
    "broadcast_day",
    "slot_set"
   ],
   "exactness": "policy_dependent",
   "status": "stable",
   "cell_systems": [],
   "edge_cases": [
    "broadcast-day-cutover-varies",
    "spring-forward-gap",
    "fall-back-fold",
    "half-hour-offset-zones",
    "broadcast-calendar-month"
   ],
   "related": [
    "iso-week",
    "the-168-axis",
    "dst-handling"
   ],
   "badges": [],
   "last_reviewed": "2026-07-29",
   "body": "<Badge tone=\"policy_dependent\">policy dependent</Badge>\n\n## Purpose\n\nA **broadcast day** is a local calendar day that does not start at local\nmidnight. It runs from a declared **cutover hour** (the operational default in\nthis KB is 06:00 local) on calendar date D to the same cutover on date D+1.\nAnything airing between midnight and the cutover is logged under the\n**previous** broadcast day's label \u2014 a program airing at 01:00 local on\n2026-03-09 is reported as part of the 2026-03-08 broadcast day, not 03-09.\nThis convention exists because overnight programming, sports that run past\nmidnight, and ad-log reconciliation all need a single day boundary that does\nnot fall in the middle of prime evening/overnight viewing. It is the calendar\nanalog of choosing where to draw a boundary in geo: someone must declare it,\nand the declaration must travel with every result.\n\n## Source and destination\n\nSource: an [instant](/docs/the-168-axis/) (UTC epoch) plus an IANA zone.\nDestination: a `broadcast_day` label (a local calendar date and its declared\ncutover) and the `slot_set` of UTC hour-of-week slots that day occupies.\n\n## Exactness: policy dependent\n\n<Badge tone=\"policy_dependent\">policy dependent</Badge> because the result\ndepends entirely on a parameter the caller must supply \u2014 the cutover hour \u2014\nand because the number of UTC hours a broadcast day spans is **not fixed**:\nit varies with the local DST calendar for that specific date. There is no\n\"just compute it\" version of this conversion; the cutover is a business rule,\nnot a physical constant, and it must be declared and echoed on every result\n(requested vs executed), exactly as the geo KB requires the requested and\nexecuted geometry to both be visible.\n\n## Algorithm\n\n```ts\nimport {\n  broadcastDayOf,\n  broadcastDayInterval,\n  broadcastDayToSlots,\n  DEFAULT_CUTOVER_HOUR,\n} from \"@/lib/time/broadcast\";\n\n// Which broadcast day does this instant belong to, in this zone?\nconst day = broadcastDayOf(Date.parse(\"2026-03-09T05:30:00Z\"), \"America/New_York\", 6);\n// -> { broadcastDate: \"2026-03-08\", cutoverHour: 6, zone: \"America/New_York\" }\n// A 00:30 local airing (before the 06:00 cutover) rolls back to the prior day.\n\n// The true [start, end) UTC interval \u2014 calendar-aware, so it honours DST.\nconst spring = broadcastDayInterval({ broadcastDate: \"2026-03-08\", cutoverHour: 6, zone: \"America/New_York\" });\n// utcHours: 23 \u2014 2026-03-08 in America/New_York is the spring-forward day (clocks skip 02:00->03:00),\n// so the 24 *local* hours from 06:00 to next-day 06:00 span only 23 UTC hours.\n\nconst fall = broadcastDayInterval({ broadcastDate: \"2026-11-01\", cutoverHour: 6, zone: \"America/New_York\" });\n// utcHours: 25 \u2014 2026-11-01 is the fall-back day; the 06:00-to-06:00 local window\n// spans 25 UTC hours because 01:00-02:00 local occurs twice.\n\n// The set of UTC hour-of-week slots the broadcast day touches (deduplicated).\nconst slots = broadcastDayToSlots({ broadcastDate: \"2026-03-08\", cutoverHour: 6, zone: \"America/New_York\" });\n// slots.length === 23 for the spring day above; wrapsWeek is true only when the\n// interval crosses Sun 23:00 UTC -> Mon 00:00 UTC.\n```\n\nAn ordinary, non-DST broadcast day produces exactly 24 UTC hours and 24\ndistinct slots. The DST day is the interop hazard: a spring-forward broadcast\nday is 23 UTC hours (23 slots), and a fall-back broadcast day is 25 UTC hours\n\u2014 but because one UTC hour genuinely recurs, `broadcastDayToSlots` still\nreturns a **deduplicated** slot set (each UTC hour-of-week slot appears once),\nso its length is 24, while `broadcastDayInterval.utcHours` correctly reports\n25 elapsed hours. Consumers that need to distinguish \"24 distinct slots\" from\n\"25 hours elapsed\" must read both fields \u2014 collapsing them loses the DST\nsignal.\n\n## Parameters\n\n<SpecGrid rows={[\n  [\"cutoverHour\", \"Local hour (0-23) the broadcast day begins. Default: 6 (06:00 local). Must be declared explicitly for any non-default operation.\"],\n  [\"zone\", \"IANA zone the cutover is evaluated in, e.g. America/New_York.\"],\n  [\"broadcastDate\", \"The local calendar date (YYYY-MM-DD) the broadcast day is labelled by \u2014 this is the OUTPUT label, not necessarily the date of the instant.\"],\n]} />\n\n## Outputs\n\n`broadcastDayOf` returns the broadcast-day label. `broadcastDayInterval`\nreturns the true `[startMs, endMs)` UTC interval and its actual `utcHours`\n(23, 24, or 25). `broadcastDayToSlots` returns the deduplicated UTC\nhour-of-week slot set and a `wrapsWeek` flag for the rare case where the\ninterval crosses the Sunday 23:00 UTC to Monday 00:00 UTC boundary.\n\n## Units and convention\n\nInstants are epoch milliseconds; zones are IANA identifiers resolved through\nluxon (the IANA tz database); UTC hour-of-week slots follow the same\nconvention as the rest of this KB \u2014 slot 0 = Monday 00:00 UTC.\n\n## DST and disambiguation behavior\n\n`broadcastDayInterval` computes the end of the day with a calendar-aware\n\"plus one day\" operation (luxon `.plus({ days: 1 })`), which honours DST\nrather than adding a fixed 24 hours. This is the correct behavior for a\nbroadcast day: a \"day\" here means one full local calendar day at the cutover\nhour, whatever its true UTC duration turns out to be. `broadcastDayToSlots`\nthen steps hour by hour across that true interval and de-duplicates by UTC\nslot, so the returned slot count reflects distinct hour-of-week buckets\ntouched, not raw elapsed hours.\n\n## Quality and provenance\n\nEvery broadcast-day result should echo the cutover hour and zone it was\ncomputed with \u2014 the requested parameters \u2014 alongside the executed\n`broadcastDate`, `utcHours`, and slot set. A result missing the cutover hour\ncannot be audited: a 1am airing logged under the wrong day is nearly always\ntraceable to an undeclared or silently-changed cutover.\n\n## Edge cases\n\n[Broadcast-day cutover is not universal](/edge-cases/broadcast-day-cutover-varies/):\nsome operations use 05:00, 02:00, or midnight, and sports/overnight feeds\noften differ from entertainment scheduling \u2014 never assume 06:00.\n[Spring-forward gap](/edge-cases/spring-forward-gap/) and\n[fall-back fold](/edge-cases/fall-back-fold/) are exactly what produce the\n23- and 25-hour broadcast days shown above.\n[Half-hour offset zones](/edge-cases/half-hour-offset-zones/) (India, Sri\nLanka, Newfoundland) mean the cutover itself may not land on a UTC hour\nboundary, so the resulting slot set can start mid-slot.\n[Broadcast calendar month](/edge-cases/broadcast-calendar-month/) explains how\nbroadcast days roll up into a broadcast month/quarter that does not align to\nthe Gregorian calendar \u2014 see [Week Systems](/docs/week-systems/).\n\n## Python parity\n\n```python\nfrom datetime import date, timedelta\nfrom zoneinfo import ZoneInfo\n\ndef broadcast_day_of(instant_utc, zone: str, cutover_hour: int = 6) -> date:\n    local = instant_utc.astimezone(ZoneInfo(zone))\n    return local.date() - timedelta(days=1) if local.hour < cutover_hour else local.date()\n```\n\nThe tested reference implementation is the TypeScript in `lib/time/broadcast.ts`;\nthis Python mirrors `broadcastDayOf` only \u2014 computing the true DST-aware\n`[start, end)` interval requires walking `zoneinfo` transition data the same\nway `broadcastDayInterval` walks luxon's, and is intentionally left to the TS\nimplementation as the single source of truth."
  },
  {
   "title": "Causal Time vs Physical Time",
   "slug": "causal-time-vs-physical-time",
   "category": "provenance",
   "summary": "Two events can have UTC times A=12:00:03, B=12:00:02 while A happened before B. Physical (UTC) order and causal order are different relations, and event sourcing needs the causal one.",
   "source_geometry": [
    "event",
    "vector_clock"
   ],
   "destination_geometry": [
    "causal_order"
   ],
   "exactness": "exact",
   "status": "stable",
   "cell_systems": [],
   "edge_cases": [
    "partial-ordering-only",
    "lamport-vector-clocks",
    "message-queue-delay",
    "duplicate-event-replay",
    "event-versioning",
    "clock-correction-jumps"
   ],
   "related": [
    "temporal-provenance",
    "measurement-semantics"
   ],
   "badges": [],
   "last_reviewed": "2026-07-29",
   "body": "This distinction is almost entirely absent from time standards, and it is the one\nthat matters most for event sourcing and multi-agent systems.\n\nTwo events can carry UTC timestamps\n\n```text\nA = 12:00:03\nB = 12:00:02\n```\n\nwhile the truth is that **A happened before B** \u2014 because of queue delays, retries,\nclock skew, or distributed clocks. The canonical hour-of-week slot orders events on\nthe wall clock; it does not, and cannot, order them causally. Physical order and\ncausal order are different relations, and for correctness you often need the causal\none.\n\n## Sometimes there is no order at all\n\nTwo events on independent nodes may have **no** happens-before relation \u2014 there is\nno fact about which came first. Forcing a UTC total order on them invents an order\nthat is an artifact of clock skew, not causality.\n\n<Callout type=\"warning\" title=\"UTC is a total order; causality is partial\">\nA UTC timestamp always gives you a tiebreak. That is exactly the trap: it will\nhappily order two concurrent events, and the order is meaningless. Use the wall\nclock for reporting; use a logical clock for ordering.\n</Callout>\n\n## Three standard tools (executable here)\n\nThe KB ships Lamport clocks, vector clocks, and hybrid logical clocks as tested\nfunctions, so causal order is checkable, not just described.\n\n```ts\nimport { vectorCompare, vectorTick, lamportTick, hlcLocal, hlcReceive, hlcCompare } from \"@/lib/time\";\n\n// Vector clocks detect concurrency \u2014 the thing UTC cannot.\nvectorCompare({ a: 1, b: 0 }, { a: 2, b: 0 }); // \"before\"\nvectorCompare({ a: 1, b: 0 }, { a: 0, b: 1 }); // \"concurrent\"  \u2190 unordered\n\n// Lamport gives a total order consistent with causality (no concurrency info).\nlamportTick(4, [7, 2]); // 8  = max(local, received) + 1\n\n// A hybrid logical clock (HLC) keeps causal order AND stays near physical time,\n// even when a clock steps backwards (VM restore, NTP correction).\nconst a = hlcLocal({ physicalMs: 0, logical: 0 }, 1000); // sender at t=1000\nconst b = hlcReceive({ physicalMs: 990, logical: 0 }, a, 990); // receiver skewed low\nhlcCompare(b, a) > 0; // true \u2014 b still sorts AFTER a despite the smaller clock\n```\n\n- **Lamport** \u2014 a monotonic counter, `max(seen) + 1`. A total order consistent with\n  causality, but it cannot tell you two events were concurrent.\n- **Vector clocks** \u2014 one counter per node. `vectorCompare` returns\n  `before`, `after`, `equal`, or `concurrent`, so concurrency is explicit.\n- **Hybrid logical clocks** \u2014 physical time plus a logical tiebreak; causal order\n  that stays close to UTC and survives a backwards clock step.\n\nThe tested reference implementation is `lib/time/causal.ts`.\n\n## Where physical and causal time meet the slot\n\nSlot the events on the wall clock for hour-of-week analysis and reporting, but keep\na causal stamp for anything order-sensitive:\n[queue reordering](/edge-cases/message-queue-delay/),\n[duplicate replay](/edge-cases/duplicate-event-replay/) (same event time, new\ningestion), and [event corrections](/edge-cases/event-versioning/) that keep the\noccurrence time fixed while the value changes. The slot says *when on the clock*;\nthe logical clock says *in what order*. A temporal-interoperability standard for\nagents and event streams has to carry both."
  },
  {
   "title": "Cycle vs Interval",
   "slug": "cycle-vs-interval",
   "category": "concepts",
   "summary": "An hour-of-week slot is a position in a repeating weekly cycle; a UTC window is a concrete dated interval. They look interchangeable and are not \u2014 every conversion result declares which one it is via temporal_kind.",
   "source_time": [
    "hour_of_week_slot",
    "daypart"
   ],
   "destination_time": [
    "utc_window",
    "instant",
    "iso_week"
   ],
   "temporal_kind": "cycle",
   "tags": [],
   "status": "stable",
   "edge_cases": [
    "no-silent-temporal-rollup"
   ],
   "related": [
    "the-168-axis",
    "resolution-and-grain",
    "requested-vs-executed-time"
   ],
   "last_reviewed": "2026-08-03",
   "body": "Two temporal objects in this KB look alike and behave completely\ndifferently. An **hour-of-week slot** (0\u2013167) is a position in a *repeating\nweekly cycle*: slot 45 is \"Tuesday 21:00 UTC\" in *every* week, forever. A\n**UTC window** like `[2026-11-01T05:00Z, 06:00Z)` is a concrete *interval*:\nit happens once. Confusing the two is the temporal version of mixing a cell\n*system* with a cell *id* \u2014 the values type-check as numbers and strings, so\nnothing complains until a set operation quietly returns the wrong answer.\n\nEvery conversion result in this KB therefore carries a `temporal_kind`\ndiscriminator: **`cycle`** or **`interval`**.\n\n## The two kinds\n\n<SpecGrid rows={[\n  [\"cycle\", \"A position in the repeating weekly cycle: an hour-of-week slot (0\u2013167) or a daypart. Recurs every week; has no year. Answers 'when in a typical week.'\"],\n  [\"interval\", \"A concrete dated span or instant: a UTC window, an epoch instant, an ISO (year \u00d7 week) coordinate. Happens once. Answers 'which actual hour in history.'\"],\n]} />\n\nA cycle value is incomplete on its own the way an H3 resolution is\nincomplete without a cell id: \"slot 45\" is not a moment until you pair it\nwith an ISO week. An interval value is fully grounded \u2014 it names a specific\nhour that already has (or will have) happened.\n\n## Why the distinction is load-bearing\n\nThe two kinds support different operations, and mixing them silently\ncorrupts the result:\n\n- **Set algebra only closes within one kind.** You can intersect or subtract\n  two intervals (concrete UTC spans) exactly. You can intersect two cycles\n  (slot-sets) exactly. You **cannot** subtract a cycle from an interval\n  without first *binding* the cycle to a specific week \u2014 the temporal analog\n  of the geo rule that set operations must stay inside one cell system.\n- **Binding is where DST enters.** Turning a cycle into an interval \u2014\n  \"slot 2 of *this* week, in *this* zone\" \u2014 is exactly where a 23- or\n  25-hour day, a skipped local hour, or a repeated local hour appears. A\n  cycle has no DST; the interval it binds to does. See\n  [Requested vs executed time](/docs/requested-vs-executed-time/).\n- **Projecting the other way is lossy.** Dropping the week from an interval\n  to get \"just the slot\" is fine for an ordinary week and *wrong* for a\n  DST-variant one, because the skipped/repeated local hour has no stable\n  cyclic home. That is why coarsening is a declared step, never a default\n  ([no silent temporal rollup](/edge-cases/no-silent-temporal-rollup/)).\n\n<Callout type=\"warning\" title=\"The join hazard\">\nJoining a table keyed by hour-of-week slot (a cycle) to one keyed by a UTC\ntimestamp (an interval) on the bare integer is the single most common\ntemporal interoperability bug. The slot repeats every week; the timestamp\ndoes not. Bind the slot to each week \u2014 or aggregate the timestamp to a\n(week \u00d7 slot) key \u2014 before the join. Never join a cycle to an interval on\nthe raw number.\n</Callout>\n\n## What every tool declares\n\nThe live conversion tools tag each result so a consumer never has to infer\nthe kind from the field names:\n\n<SpecGrid rows={[\n  [\"time_to_canonical\", \"cycle \u2014 the product is the hour-of-week slot; executed.weekSlotKey is its interval realization in one week\"],\n  [\"time_instant_to_slot\", \"cycle \u2014 a bare slot 0\u2013167\"],\n  [\"time_daypart_to_slots\", \"cycle \u2014 a weekly slot-set\"],\n  [\"time_slot_uncertainty\", \"cycle \u2014 candidate slots a \u00b1 window touches\"],\n  [\"time_now\", \"interval \u2014 a concrete instant\"],\n  [\"time_week_slot_key\", \"interval \u2014 a concrete (year \u00d7 week \u00d7 slot) key\"],\n  [\"time_parse_week_slot_key\", \"interval \u2014 same, parsed back\"],\n  [\"time_slot_to_utc_window\", \"interval \u2014 the concrete UTC hour a slot occupies in a stated week\"],\n  [\"time_iso_week_of\", \"interval \u2014 a concrete ISO week\"],\n  [\"time_broadcast_day_slots\", \"interval \u2014 a specific dated broadcast day (23/24/25 UTC hours)\"],\n  [\"time_holidays_for\", \"interval \u2014 concrete dated holidays\"],\n]} />\n\nThe rule of thumb: if the result would be identical next week, it is a\n**cycle**; if it names a specific year and week, it is an **interval**. The\n`time_slot_to_utc_window` and `time_to_canonical` pair is the canonical\nbridge between them \u2014 bind a cycle to a week to get an interval, and read an\ninterval's slot to get back the cycle."
  },
  {
   "title": "Daypart to Slots",
   "slug": "daypart-to-slots",
   "category": "calendar",
   "summary": "A daypart is a local clock band such as primetime; mapping it to canonical UTC slots for a zone and ISO week produces a weighted slot-set whenever the band straddles a UTC hour boundary.",
   "source_geometry": [
    "daypart",
    "iana_zone",
    "iso_week"
   ],
   "destination_geometry": [
    "slot_set"
   ],
   "exactness": "weighted",
   "status": "stable",
   "cell_systems": [],
   "edge_cases": [
    "half-hour-offset-zones",
    "forty-five-minute-offset-zones",
    "sub-hour-band-straddle",
    "southern-hemisphere-reversed-dst",
    "utc-canonical-vs-local-experience",
    "extreme-offset-span"
   ],
   "related": [
    "the-168-axis",
    "measurement-semantics",
    "timezone-database"
   ],
   "badges": [],
   "last_reviewed": "2026-07-29",
   "body": "<Badge tone=\"weighted\">weighted</Badge>\n\n## Purpose\n\nA **daypart** \u2014 \"primetime,\" \"morning drive,\" \"overnight\" \u2014 is a band of\n**local** clock hours on a set of weekdays. It is a scheduling and\nmeasurement concept defined the way audiences experience it: 8pm feels like\nprimetime everywhere, regardless of what UTC hour that is. The canonical unit\nin this KB, however, is the UTC hour-of-week slot. Converting a daypart to\nslots therefore requires resolving each local hour through the tz database\nfor a **specific** IANA zone and a **specific** ISO week, because the\nlocal-to-UTC offset shifts across DST \u2014 the same local daypart maps to a\ndifferent UTC slot set in a spring week than in a fall week.\n\n## Source and destination\n\nSource: a `daypart` definition (local hours + weekdays), an `iana_zone`, and\nan `iso_week`. Destination: a `slot_set` of UTC hour-of-week slots.\n\n## Exactness: weighted\n\n<Badge tone=\"weighted\">weighted</Badge>. Sub-hour offsets (India, Sri Lanka,\nNewfoundland at half-hour offsets; Nepal, Chatham Islands at 45-minute\noffsets) and dayparts whose edges fall on the half hour (a \"daytime\" band\nrunning 9:30am-4:30pm local) both mean the local band does not always align\nto whole UTC hours. When it doesn't, a single local hour straddles two UTC\nslots, so the correct output is not a clean slot list but a **weighted\nslot-set** \u2014 the fraction of coverage each UTC slot receives \u2014 the direct\ntemporal analog of the geo KB's weighted crosswalk for a polygon that\nstraddles a cell boundary. Rounding to whole-slot membership by majority\noverlap is a documented, lossy simplification, not the default behavior.\n\n## Algorithm\n\n```ts\nimport { STANDARD_DAYPARTS, daypartToUtcSlots, utcSlotToDaypart } from \"@/lib/time/daypart\";\n\nconst primetime = STANDARD_DAYPARTS.find((d) => d.id === \"primetime\")!;\n// { id: \"primetime\", name: \"Primetime (8p-11p)\", hours: [20, 21, 22] }\n\n// Expand to UTC slots for a specific zone and ISO week (DST-correct for that week).\nconst janSlots = daypartToUtcSlots(primetime, \"America/New_York\", 2026, 3);\n// Early January: EST is UTC-5, so 20:00-23:00 local -> 01:00-04:00 UTC next day.\n\nconst julySlots = daypartToUtcSlots(primetime, \"America/New_York\", 2026, 29);\n// Mid-July: EDT is UTC-4, so the SAME local daypart resolves to a different\n// UTC slot set than in week 3 \u2014 a one-hour shift purely from DST.\n\n// Inverse: which daypart does a given UTC slot fall into, for a zone + week?\nconst back = utcSlotToDaypart(julySlots.slots[0]!, \"America/New_York\", 2026, 29);\n// -> { daypart: \"primetime\", localHour: 20, localWeekday: <Mon0 weekday> }\n```\n\n`STANDARD_DAYPARTS` is a conventional US broadcast scheme (overnight, morning,\ndaytime, early fringe, early news, prime access, primetime, late news) defined\npurely in **local hours**. It is a labelled default, not a universal\nstandard \u2014 Nielsen, individual networks, and international broadcasters each\ndefine their own bands, sometimes at 15-minute resolution. Treat it the same\nway this KB treats a conversion profile: override the hours/days per market\nrather than assuming the US scheme applies elsewhere.\n\n## Parameters\n\n<SpecGrid rows={[\n  [\"daypart\", \"An id/name/hours definition with optional days. hours are local clock hours 0-23; days default to all seven (Monday0-Sunday6).\"],\n  [\"zone\", \"IANA zone the local hours are resolved in.\"],\n  [\"isoYear / isoWeek\", \"The specific ISO week to resolve against \u2014 required because the local-UTC offset depends on the DST calendar for that week, not a fixed constant.\"],\n]} />\n\n## Outputs\n\nA `DaypartSlots` record: the daypart id, zone, ISO year/week, and the sorted,\ndeduplicated set of UTC hour-of-week slots the daypart occupies in that week.\nWhere a sub-hour offset or a non-hour-aligned band edge applies, treat that\nset as coverage-weighted rather than binary membership \u2014 carry the overlap\nfraction through to any downstream reporting rather than silently rounding.\n\n## Units and convention\n\nLocal hours are integers 0-23; UTC slots follow the KB-wide convention (slot 0\n= Monday 00:00 UTC); zones are IANA identifiers; the tz engine is luxon.\n\n## DST and disambiguation behavior\n\n`daypartToUtcSlots` resolves each (weekday, local hour) pair as a wall time in\nthe target ISO week via luxon, so it inherits the correct DST offset for that\nspecific week automatically. An hour skipped entirely by a spring-forward gap\n(the local hour that never occurs) is silently excluded from the slot set\nrather than raising \u2014 callers doing exact accounting (e.g. ad-slot inventory)\nshould independently check for gap weeks via [DST Handling](/docs/dst-handling/)\nif that hour mattered to them.\n\n## Quality and provenance\n\nEvery result should carry the zone, ISO week, and daypart definition used \u2014\nthe same daypart id resolves to a different slot set in different weeks, so\nthe week is not optional context, it is part of the key.\n\n## Edge cases\n\n[Half-hour](/edge-cases/half-hour-offset-zones/) and\n[45-minute](/edge-cases/forty-five-minute-offset-zones/) offset zones, and the\ngeneral [sub-hour band straddle](/edge-cases/sub-hour-band-straddle/) case,\nare why the output is weighted rather than a clean partition.\n[Southern-Hemisphere reversed DST](/edge-cases/southern-hemisphere-reversed-dst/)\nmeans \"primetime\" in Sydney and New York shift in opposite calendar\ndirections across the year \u2014 never compare local-experience dayparts across\nhemispheres by UTC slot alone; see\n[Measurement Semantics](/docs/measurement-semantics/) for the general\n[UTC-canonical-vs-local-experience](/edge-cases/utc-canonical-vs-local-experience/)\ntension. [Extreme offset span](/edge-cases/extreme-offset-span/) means a\ndaypart aggregated across many zones can produce a slot set that wraps the\nentire week.\n\n## Python parity\n\n```python\nfrom zoneinfo import ZoneInfo\nfrom datetime import datetime\n\ndef daypart_to_utc_slots(hours, days, zone: str, iso_year: int, iso_week: int) -> set[int]:\n    slots = set()\n    for day_mon0 in days:\n        for hour in hours:\n            try:\n                local = datetime.fromisocalendar(iso_year, iso_week, day_mon0 + 1).replace(\n                    hour=hour, tzinfo=ZoneInfo(zone)\n                )\n            except ValueError:\n                continue  # hour skipped by a spring-forward gap\n            utc = local.astimezone(ZoneInfo(\"UTC\"))\n            weekday_mon0 = (utc.isoweekday() - 1)\n            slots.add(weekday_mon0 * 24 + utc.hour)\n    return slots\n```\n\nThe tested reference implementation is the TypeScript in `lib/time/daypart.ts`;\nthis Python mirrors `daypartToUtcSlots` using `datetime.fromisocalendar` (3.9+)\nand `zoneinfo`, catching the `ValueError` a gap produces the same way the TS\nskips an invalid luxon `DateTime`."
  },
  {
   "title": "DST Handling",
   "slug": "dst-handling",
   "category": "systems",
   "summary": "Daylight saving time creates a nonexistent local time at spring-forward and an ambiguous one at fall-back; both must be resolved by a declared, explicit policy rather than a hardcoded transition hour.",
   "source_geometry": [
    "local_datetime",
    "iana_zone"
   ],
   "destination_geometry": [
    "instant"
   ],
   "status": "stable",
   "cell_systems": [],
   "edge_cases": [
    "spring-forward-gap",
    "fall-back-fold",
    "partial-hour-dst-shift",
    "dst-transition-time-varies",
    "southern-hemisphere-reversed-dst",
    "non-dst-region-inside-dst-country"
   ],
   "related": [
    "timestamp-to-slot",
    "timezone-database"
   ],
   "badges": [],
   "last_reviewed": "2026-07-29",
   "body": "## Purpose\n\nDaylight saving time (DST) breaks the assumption that local wall-clock time\nmaps one-to-one onto UTC instants. Twice a year, in every zone that observes\nit, the mapping becomes either **undefined** (a wall time that never occurs)\nor **two-valued** (a wall time that occurs twice). Every local-to-slot\nconversion in this KB \u2014 [timestamp-to-slot](/docs/timestamp-to-slot/),\n[broadcast day](/docs/broadcast-day/), [daypart-to-slots](/docs/daypart-to-slots/) \u2014\nmust detect these two conditions explicitly and apply a declared policy,\nnever a silent guess.\n\n## The spring-forward gap\n\nWhen clocks move forward (e.g. America/New_York, 2026-03-08: 02:00 local\njumps straight to 03:00), every wall time in the skipped hour \u2014 02:00\nthrough 02:59 \u2014 **does not exist** as a local reading in that zone on that\ndate. Only one true instant exists on the far side of the gap.\n\n## The fall-back fold\n\nWhen clocks move back (e.g. America/New_York, 2026-11-01: 02:00 local\nbecomes 01:00 again), every wall time in the repeated hour \u2014 01:00 through\n01:59 \u2014 occurs **twice**: once at the pre-transition (larger) UTC offset and\nonce at the post-transition (smaller) offset. These are two distinct UTC\ninstants, one hour apart, that read identically on a local clock.\n\n## Detection and policy\n\n```ts\nimport { localToSlot } from \"@/lib/time/slot\";\n\n// GAP: 2026-03-08 02:30 does not exist in America/New_York.\nconst gap = localToSlot({ year: 2026, month: 3, day: 8, hour: 2, minute: 30 }, \"America/New_York\", \"earliest\");\n// -> { wasNonexistent: true, wasAmbiguous: false, disambiguationApplied: \"earliest\",\n//      utc: \"2026-03-08T07:30:00.000Z\", offsetMinutes: -240 }\n// Only one valid instant exists on either side of the gap; both \"earliest\" and\n// \"latest\" resolve to it (the post-transition instant, EDT/-04:00).\n\n// FOLD: 2026-11-01 01:30 occurs twice in America/New_York.\nconst earliest = localToSlot({ year: 2026, month: 11, day: 1, hour: 1, minute: 30 }, \"America/New_York\", \"earliest\");\n// -> wasAmbiguous: true, offsetMinutes: -240 (EDT, pre-transition, FIRST occurrence)\n\nconst latest = localToSlot({ year: 2026, month: 11, day: 1, hour: 1, minute: 30 }, \"America/New_York\", \"latest\");\n// -> wasAmbiguous: true, offsetMinutes: -300 (EST, post-transition, SECOND occurrence)\n// Same wall time, same zone -> two different UTC instants one hour apart,\n// and therefore two different hour-of-week slots.\n\n// REJECT: refuse to silently pick, useful where the caller must be forced to disambiguate.\ntry {\n  localToSlot({ year: 2026, month: 3, day: 8, hour: 2, minute: 30 }, \"America/New_York\", \"reject\");\n} catch (e) {\n  // \"Nonexistent local time (spring-forward gap): ... is skipped.\"\n}\n```\n\n`localToSlot` returns `wasNonexistent` and `wasAmbiguous` flags plus\n`disambiguationApplied` on every call, so a gap or fold is never invisible\neven when a default policy quietly resolved it \u2014 the requested-vs-executed\nrecord makes the disambiguation auditable (see\n[Requested vs Executed Time](/docs/requested-vs-executed-time/)).\n\n## Never hardcode the transition\n\n<Callout type=\"danger\" title=\"02:00 is a US convention, not a rule\">\nIt is tempting to hardcode \"DST transitions happen at 02:00 local.\" They do\nnot, universally. The transition **hour** and **date** vary by zone: some\ntransition at 00:00, 01:00, or 03:00 local, or at 23:00 the prior day; dates\ndiffer by country even within the same broad region; and the Southern\nHemisphere transitions in the opposite calendar months from the Northern\nHemisphere (Australia's DST begins in October and ends in April). Always\nresolve transitions through the IANA tz database (luxon here), never a\nconstant.\n</Callout>\n\n## Partial-hour shifts\n\nNot every DST transition moves the clock by a full hour. Lord Howe Island\n(Australia) shifts by only 30 minutes (+10:30 standard to +11:00 DST), and\nseveral historical transitions elsewhere used 20- or 40-minute shifts. A\n30-minute gap or fold is genuinely half an hour of nonexistent or ambiguous\nwall time, not a full slot's worth \u2014 compute the shift magnitude from the tz\ndatabase's actual transition data rather than assuming 60 minutes, since a\npolicy built for a one-hour fold will misapportion a 30-minute one.\n\n## Reversed and absent DST\n\n[Southern-Hemisphere reversed DST](/edge-cases/southern-hemisphere-reversed-dst/):\nbecause the DST calendar flips by hemisphere, the same UTC slot corresponds\nto a different local season (and often a different local hour) in Sydney\nversus New York at the same time of year \u2014 local-experience comparisons\n(see [Measurement Semantics](/docs/measurement-semantics/)) must never assume\na shared DST calendar across hemispheres.\n[Non-DST region inside a DST country](/edge-cases/non-dst-region-inside-dst-country/):\nArizona observes no DST while the rest of US Mountain time does; Queensland\ndiffers from New South Wales within Australia. A \"Mountain Time\" or country\nlabel is ambiguous for roughly half the year in these cases \u2014 resolve by the\nspecific IANA zone (`America/Phoenix` vs `America/Denver`), never by a\ncountry or a generic offset name.\n\n## Quality and provenance\n\nEvery conversion through a gap or fold should carry, at minimum:\n`wasNonexistent`, `wasAmbiguous`, `disambiguationApplied`, and the resolved\n`offsetMinutes` \u2014 enough for a downstream consumer to know not just which\ninstant was chosen but that a choice was necessary at all. `lossless` (in\n`lib/time/provenance.ts`) is `false` whenever either flag is set, marking the\nconversion as one where requested and executed cannot both hold exactly.\n\n## Edge cases\n\n[Spring-forward gap](/edge-cases/spring-forward-gap/) and\n[fall-back fold](/edge-cases/fall-back-fold/) are this page's core subject.\n[Partial-hour DST shift](/edge-cases/partial-hour-dst-shift/),\n[DST transition time varies by zone](/edge-cases/dst-transition-time-varies/),\n[Southern-Hemisphere reversed DST](/edge-cases/southern-hemisphere-reversed-dst/),\nand [non-DST region inside a DST country](/edge-cases/non-dst-region-inside-dst-country/)\nare the specific failure modes of assuming a single, universal DST rule.\n\n## Python parity\n\nPython's `zoneinfo` + `datetime` handle the same two hazards via the\n`fold` attribute (PEP 495) rather than a returned flag: `fold=0` selects the\nfirst (pre-transition) occurrence of an ambiguous fold, `fold=1` selects the\nsecond, and a nonexistent (gap) time is silently normalized forward when\n`.astimezone()` is called on it.\n\n```python\nfrom datetime import datetime\nfrom zoneinfo import ZoneInfo\n\nzone = ZoneInfo(\"America/New_York\")\n\n# FOLD: fold=0 = earliest (EDT, pre-transition); fold=1 = latest (EST, post-transition).\nearliest = datetime(2026, 11, 1, 1, 30, tzinfo=zone, fold=0)\nlatest = datetime(2026, 11, 1, 1, 30, tzinfo=zone, fold=1)\nprint(earliest.utcoffset(), latest.utcoffset())  # -4:00:00 then -5:00:00\n\n# GAP: 2026-03-08 02:30 does not exist; .astimezone() resolves it forward.\nnonexistent = datetime(2026, 3, 8, 2, 30, tzinfo=zone)\nresolved = nonexistent.astimezone(ZoneInfo(\"UTC\"))\n```\n\n`fold` is Python's disambiguation policy equivalent to this KB's\n`\"earliest\"`/`\"latest\"` parameter; there is no built-in `\"reject\"` behavior in\n`zoneinfo` \u2014 an application that needs to refuse ambiguous input must detect\nthe fold explicitly (compare the UTC offsets at `fold=0` and `fold=1`; if they\ndiffer, the wall time is ambiguous) before deciding, the same detection\n`localToSlot` performs internally. The tested reference implementation\nremains the TypeScript in `lib/time/slot.ts`."
  },
  {
   "title": "Holidays",
   "slug": "holidays",
   "category": "calendar",
   "summary": "National holiday flags derived from public-calendar rules rather than a scraped feed; v0 covers US, UK, and Canada, and a holiday is a 24-local-hour slot-set, not a single slot.",
   "source_geometry": [
    "local_date",
    "country"
   ],
   "destination_geometry": [
    "holiday_flag",
    "slot_set"
   ],
   "exactness": "approximate",
   "status": "stable",
   "cell_systems": [],
   "edge_cases": [
    "substitute-day-holidays",
    "movable-and-regional-holidays"
   ],
   "related": [
    "week-systems"
   ],
   "badges": [],
   "last_reviewed": "2026-07-29",
   "body": "<Badge tone=\"approximate\">approximate</Badge>\n\n## Purpose\n\nA holiday flag answers \"is this local calendar date a national holiday in\nthis country\" \u2014 used to explain otherwise-anomalous demand, traffic, or\naudience patterns without a manual lookup table. This KB computes holidays\nfrom **rules** (fixed dates, the nth or last weekday of a month, and the\nGregorian Easter computus) rather than from a scraped or licensed calendar\nfeed. That makes the result small, auditable, and reproducible for any year,\nat the cost of completeness: v0 covers US federal, UK bank, and Canada\nnational holidays only, and does not attempt regional, lunar, or\nlocally-observed holidays. Critically, a holiday is not a slot \u2014 it is a\n**24-local-hour band**, i.e. a slot-set once mapped to UTC, exactly like a\n[broadcast day](/docs/broadcast-day/).\n\n## Source and destination\n\nSource: a `local_date` (YYYY-MM-DD) and a `country` code. Destination: a\n`holiday_flag` (matched holiday name, if any) and, when mapped to UTC for\nmeasurement, a `slot_set` covering that local calendar date's 24 hours.\n\n## Exactness: approximate\n\n<Badge tone=\"approximate\">approximate</Badge>. The rule set is exact for what\nit models (a fixed date always falls on that date; an nth-weekday rule always\nresolves the same way; the Easter computus is a well-defined deterministic\nalgorithm), but the **coverage** is approximate relative to \"all holidays\nthat matter\" in a given country: it omits state/provincial holidays,\nsubstitute (in-lieu) days by default, and any lunar or movable holiday not\nalready enumerated. Treat a `false` result as \"not flagged by this rule set,\"\nnot as an authoritative \"not a holiday anywhere in this jurisdiction.\"\n\n## Algorithm\n\n```ts\nimport { holidaysFor, isHoliday, easterSunday } from \"@/lib/time/holidays\";\n\nholidaysFor(\"US\", 2026).find((h) => h.name === \"Thanksgiving\");\n// -> { date: \"2026-11-26\", name: \"Thanksgiving\", country: \"US\" }\n// (4th Thursday of November \u2014 nthWeekday(year, month=11, isoWeekday=Thursday, n=4))\n\neasterSunday(2026);\n// -> { month: 4, day: 5 }  (2026-04-05, via the Anonymous/Meeus computus)\n\nisHoliday(\"2026-04-05\", \"UK\");\n// -> null \u2014 Easter SUNDAY itself is not a UK bank holiday; Good Friday\n// (2026-04-03) and Easter Monday (2026-04-06) are, and are computed as\n// offsets from easterSunday() rather than looked up separately.\n\nisHoliday(\"2026-07-04\", \"US\");\n// -> { date: \"2026-07-04\", name: \"Independence Day\", country: \"US\" }\n```\n\nEach country's holiday list is a small, explicit array built from three\nprimitives: a literal fixed date (`iso(y, 12, 25)` for Christmas), an\nnth-weekday rule (`nthWeekday(y, 1, 1, 3)` for the third Monday in January \u2014\nMartin Luther King Jr. Day), and a last-weekday rule (`lastWeekday(y, 5, 1)`\nfor the last Monday in May \u2014 Memorial Day). Easter-derived UK holidays\ncompute an offset in days from `easterSunday(y)` rather than encoding their\nown date rule, so they stay correct in every year without a separate lookup\ntable.\n\n## Parameters\n\n<SpecGrid rows={[\n  [\"country\", \"One of US | UK | CA in v0. Each has its own rule builder.\"],\n  [\"year\", \"Calendar year the rules are evaluated for; all rules are computable for any year, past or future.\"],\n  [\"dateISO\", \"For isHoliday: the local calendar date to test, YYYY-MM-DD.\"],\n]} />\n\n## Outputs\n\n`holidaysFor` returns every `Holiday` (`{date, name, country}`) the rule set\nproduces for that country and year. `isHoliday` returns the matching\n`Holiday` or `null`. `easterSunday` returns `{month, day}` for the Gregorian\nEaster Sunday of a given year \u2014 the anchor several UK holidays are computed\nfrom.\n\n## Units and convention\n\nHoliday dates are local calendar dates (YYYY-MM-DD) in the country's own\ncivil calendar, not UTC instants. Mapping a holiday to the KB's canonical UTC\nslots requires an explicit zone and produces a 24-hour slot-set (which, like\na [broadcast day](/docs/broadcast-day/), may be 23 or 25 UTC hours across a\nDST transition in that country) rather than a single slot.\n\n## DST and disambiguation behavior\n\nHoliday date computation itself has no DST dependency \u2014 it is pure calendar\narithmetic. DST only enters once a holiday's local date is converted to a\nUTC slot-set for measurement, at which point the same gap/fold handling as\n[DST Handling](/docs/dst-handling/) applies to the conversion, not to the\nholiday rule.\n\n## Quality and provenance\n\nThis is intentionally a small, auditable rule set, not a substitute for an\nauthoritative feed anywhere legal observance matters (payroll, banking\nclosures, contractual SLAs). State the tzdb/rule-set version alongside any\nholiday-flag output, and do not silently extend v0's three-country coverage\nby inference \u2014 an unmodeled country should report \"unknown,\" not \"not a\nholiday.\"\n\n## Edge cases\n\n[Substitute (in-lieu) holiday days](/edge-cases/substitute-day-holidays/): when\na fixed-date holiday lands on a Saturday or Sunday, many countries (UK, much\nof APAC) observe a substitute weekday instead \u2014 this rule set does not apply\nin-lieu substitution, so a fixed-date holiday falling on a weekend is flagged\non its nominal date only, which will disagree with the country's actual\nobserved closure date. [Movable and regional holidays](/edge-cases/movable-and-regional-holidays/):\nlunar-calendar holidays (Eid, Diwali, Lunar New Year) shift against the\nGregorian calendar year to year and are not covered by this rule set at all;\nregional holidays (US state, Canadian province) are omitted from the\nnational-only lists above.\n\n## Python parity\n\n```python\ndef easter_sunday(year: int) -> tuple[int, int]:\n    a = year % 19\n    b, c = divmod(year, 100)\n    d, e = divmod(b, 4)\n    f = (b + 8) // 25\n    g = (b - f + 1) // 3\n    h = (19 * a + b - d - g + 15) % 30\n    i, k = divmod(c, 4)\n    l = (32 + 2 * e + 2 * i - h - k) % 7\n    m = (a + 11 * h + 22 * l) // 451\n    month = (h + l - 7 * m + 114) // 31\n    day = (h + l - 7 * m + 114) % 31 + 1\n    return month, day  # e.g. easter_sunday(2026) == (4, 5)\n```\n\nThe tested reference implementation is the TypeScript in `lib/time/holidays.ts`;\nthis Python is the identical Anonymous/Meeus computus (integer division in\nplace of `Math.floor`), producing the same `(month, day)` for every year. The\nfixed-date and nth/last-weekday rules translate directly using\n`calendar.monthrange` or manual weekday arithmetic and are omitted here for\nbrevity \u2014 the algorithm is the same as `nthWeekday`/`lastWeekday` above."
  },
  {
   "title": "ISO Week",
   "slug": "iso-week",
   "category": "calendar",
   "summary": "ISO-8601 defines the week as Monday-start with week 1 containing the first Thursday of the year, producing a week-numbering year that can diverge from the calendar year and years with 53 weeks.",
   "source_geometry": [
    "instant"
   ],
   "destination_geometry": [
    "iso_week",
    "week_slot_key"
   ],
   "exactness": "exact",
   "status": "stable",
   "cell_systems": [],
   "edge_cases": [
    "week-year-boundary",
    "fifty-three-week-years",
    "week-numbering-systems",
    "slot-origin-convention",
    "date-line-weekday-divergence"
   ],
   "related": [
    "week-systems",
    "the-168-axis"
   ],
   "badges": [],
   "last_reviewed": "2026-07-29",
   "body": "<Badge tone=\"exact\">exact</Badge>\n\n## Purpose\n\nThis KB's canonical bucket for a moment is the pair (ISO week, hour-of-week\nslot) \u2014 the [\"168 axis\"](/docs/the-168-axis/) coordinate. The slot repeats\nevery week (0-167), so it alone cannot identify a unique moment; it must be\npaired with an unambiguous week identifier. ISO-8601 defines that identifier:\nweeks start on Monday, and week 1 of an ISO year is the week containing that\nyear's first Thursday (equivalently: the week containing January 4th, or the\nfirst week with at least four days in the new year). Both the week and the\nslot are computed purely from the UTC instant, so the (isoYear, isoWeek,\nslot) triple is timezone-independent and reproducible by any system that\nimplements ISO-8601 correctly.\n\n## Source and destination\n\nSource: an [instant](/docs/timestamp-to-slot/) (UTC epoch). Destination: an\n`iso_week` (isoYear, isoWeek, isoWeekday) and a `week_slot_key` \u2014 the combined\n\"2026-W29-S045\" string.\n\n## Exactness: exact\n\n<Badge tone=\"exact\">exact</Badge>. ISO-8601 week numbering is a deterministic\nfunction of the calendar date with no external parameters, no DST\ndependency, and no configurable policy \u2014 unlike [broadcast day](/docs/broadcast-day/)\nor [daypart](/docs/daypart-to-slots/) conversions, there is nothing to\ndeclare. The only discipline required is carrying the week-numbering **year**\nalongside the week number, because \u2014 see below \u2014 it is not always the\ncalendar year.\n\n## Algorithm\n\n```ts\nimport { isoWeekOf, isoWeekStartUtc, isoWeeksInYear, weekSlotKey, parseWeekSlotKey } from \"@/lib/time/isoweek\";\n\nisoWeekOf(Date.parse(\"2026-07-16T12:00:00Z\"));\n// -> { isoYear: 2026, isoWeek: 29, isoWeekday: 4 }  (Thursday)\n\n// The week-year/calendar-year mismatch around January 1:\nisoWeekOf(Date.parse(\"2027-01-01T00:00:00Z\"));\n// -> { isoYear: 2026, isoWeek: 53, isoWeekday: 5 }\n// 2027-01-01 is a Friday that falls in the LAST ISO week of 2026, not week 1 of 2027,\n// because that week's Thursday (2026-12-31) is still in 2026.\n\n// 2026 is a 53-week ISO year:\nisoWeeksInYear(2026); // -> 53\n\n// Reverse: the UTC instant that begins a given ISO week.\nisoWeekStartUtc(2026, 29); // -> epoch ms of Monday 2026-07-13T00:00:00Z\n\n// The combined (week x slot) key used as the canonical bucket everywhere in this KB.\nweekSlotKey(Date.parse(\"2026-07-16T21:00:00Z\"));\n// -> { isoYear: 2026, isoWeek: 29, slot: 93, key: \"2026-W29-S093\" }\n\nparseWeekSlotKey(\"2026-W29-S045\");\n// -> { isoYear: 2026, isoWeek: 29, slot: 45, key: \"2026-W29-S045\" }\n```\n\n## Parameters\n\n<SpecGrid rows={[\n  [\"epochMs\", \"UTC instant, as epoch milliseconds. isoWeekOf and weekSlotKey take only this.\"],\n  [\"isoYear, isoWeek\", \"For isoWeekStartUtc: the week-numbering year and week number (1-52 or 1-53) to resolve to a starting instant.\"],\n]} />\n\n## Outputs\n\n`isoWeekOf` returns `{isoYear, isoWeek, isoWeekday}` (weekday 1=Monday through\n7=Sunday). `weekSlotKey` returns the same plus the UTC hour-of-week `slot`\n(0-167) and the canonical string `key`, formatted `YYYY-Www-Sss` with\nzero-padded week and slot (e.g. `2026-W29-S045`). `isoWeeksInYear` returns 52\nor 53. `isoWeekStartUtc` returns the epoch ms of the Monday 00:00 UTC that\nbegins the given week \u2014 the anchor that turns a repeating slot into a unique\ninstant (see [Slot-to-UTC-Window](/docs/slot-to-utc-window/)).\n\n## Units and convention\n\nInstants are epoch milliseconds; ISO weekdays are 1 (Monday) through 7\n(Sunday); slots follow the KB-wide convention (slot 0 = Monday 00:00 UTC),\nwhich is deliberately aligned with the ISO week's Monday start so that slot 0\nof any week is always that week's opening instant. The tz engine is luxon,\ncomputed here in the UTC zone (ISO week numbering itself has no zone\ndependency once the instant is fixed).\n\n## DST and disambiguation behavior\n\nNone. ISO week computation operates on the UTC instant only; DST is a\nlocal-time phenomenon that affects how a *local* wall-clock reading maps to\nthat instant ([timestamp-to-slot](/docs/timestamp-to-slot/)), not how the\ninstant maps to its ISO week.\n\n## Quality and provenance\n\nBecause this conversion is exact and parameter-free, the main provenance\nrequirement is structural: never persist or transmit a bare week number.\nAlways carry `isoYear` alongside `isoWeek`, and prefer the combined `key`\nstring as the join key between systems \u2014 it is self-describing and avoids\nthe year-boundary bug below by construction.\n\n## Edge cases\n\n[Week-year boundary](/edge-cases/week-year-boundary/): the ISO week-numbering\nyear diverges from the calendar year in the days around January 1 in either\ndirection \u2014 `2026-12-31` can fall in `2027-W01`, and `2027-01-01` falls in\n`2026-W53`, per the runnable example above. A system that logs \"week 53\" or\n\"week 1\" without its year is unreconcilable across this boundary.\n[Fifty-three-week years](/edge-cases/fifty-three-week-years/): 2026 has 53\nISO weeks (a year has 53 ISO weeks when it starts on a Thursday, or is a leap\nyear starting on Wednesday); code that hardcodes 52 weeks per year will\nmisalign year-over-year comparisons in a 53-week year.\n[Week-numbering systems](/edge-cases/week-numbering-systems/): ISO is not the\nonly week system in use \u2014 see [Week Systems](/docs/week-systems/) for the\ncomparison to US/retail and broadcast weeks.\n[Slot-origin convention](/edge-cases/slot-origin-convention/): the KB's slot 0\nis defined to align with the ISO week's Monday start; a system assuming a\nSunday-start week or local-midnight origin will be off by a fixed offset.\n[Date-line weekday divergence](/edge-cases/date-line-weekday-divergence/):\nnear the International Date Line, a local weekday can differ from the\nweekday implied by the UTC instant's ISO week \u2014 this page's `isoWeekday` is\nalways the UTC weekday, not any local one.\n\n## Python parity\n\n```python\nfrom datetime import datetime, timezone\n\ndef iso_week_of(epoch_ms: int) -> tuple[int, int, int]:\n    dt = datetime.fromtimestamp(epoch_ms / 1000, tz=timezone.utc)\n    iso_year, iso_week, iso_weekday = dt.isocalendar()\n    return iso_year, iso_week, iso_weekday\n\ndef week_slot_key(epoch_ms: int) -> str:\n    iso_year, iso_week, iso_weekday = iso_week_of(epoch_ms)\n    dt = datetime.fromtimestamp(epoch_ms / 1000, tz=timezone.utc)\n    slot = (iso_weekday - 1) * 24 + dt.hour  # isoWeekday 1=Mon..7=Sun -> Mon0\n    return f\"{iso_year}-W{iso_week:02d}-S{slot:03d}\"\n```\n\nPython's built-in `datetime.isocalendar()` (3.9+) implements ISO-8601 week\nnumbering natively and agrees with `isoWeekOf` for every instant, including\nthe 53-week and year-boundary cases above \u2014 no library beyond the standard\n`datetime` module is required for this conversion. The tested reference\nimplementation remains the TypeScript in `lib/time/isoweek.ts`."
  },
  {
   "title": "Measurement Semantics",
   "slug": "measurement-semantics",
   "category": "semantics",
   "summary": "Two platforms can agree on the same UTC slot and still measure different things, because time carries local-experience, causal, and attribution meanings the slot alone does not resolve.",
   "source_geometry": [
    "instant",
    "hour_of_week_slot"
   ],
   "destination_geometry": [
    "hour_of_week_slot"
   ],
   "status": "stable",
   "cell_systems": [],
   "edge_cases": [
    "utc-canonical-vs-local-experience",
    "event-vs-ingestion-vs-report-time",
    "attribution-window-time",
    "no-silent-temporal-rollup",
    "slot-boundary-dedup"
   ],
   "related": [
    "requested-vs-executed-time",
    "resolution-and-grain"
   ],
   "badges": [],
   "last_reviewed": "2026-07-29",
   "body": "The geo-interoperability KB makes a point of stating that geometry does\nnot fully define a measurement target \u2014 two systems can agree on a\npolygon and still disagree about what counts as \"inside\" it. The temporal\nside has the direct analog: two systems can agree on the exact same\ncanonical (ISO week x slot) coordinate and still be measuring two\ndifferent things. Getting the slot right is necessary; it is not\nsufficient.\n\n## UTC-canonical vs. local experience\n\nThe canonical unit of this KB is UTC. But almost every human-meaningful\ntemporal concept \u2014 primetime, lunch, the morning commute, \"8pm\" \u2014 is a\n**local** experience, defined relative to the sun and the clock on the\nwall, not relative to Greenwich. Holding the UTC slot fixed across\nmarkets does not hold the local experience fixed: slot 69 (Wednesday\n21:00 UTC) is prime-time evening in New York, mid-afternoon in Los\nAngeles, and the middle of the Thursday morning commute in Mumbai. A\ncross-market comparison that joins on the bare UTC slot and interprets the\nresult as \"the same daypart everywhere\" has silently substituted a UTC\nfact for a local claim.\n\n<Callout type=\"warning\" title=\"Fixing the UTC slot is not fixing the local hour\">\nComparing behavior \"at the same UTC slot\" across zones answers a\nUTC-canonical question. Comparing behavior \"at 8pm local\" across zones\nanswers a different, local-experience question \u2014 and requires computing\na *different* UTC slot per zone per week, not reusing one slot for every\nmarket. Declare which question is being asked before running the\ncomparison; see [half-hour-offset-zones](/edge-cases/half-hour-offset-zones/)\nand [Daypart to slots](/docs/daypart-to-slots/) for how a local band is\ntranslated into per-zone UTC slot-sets correctly.\n</Callout>\n\nThis is the direct temporal analog of requested-vs-executed geography:\nthe local experience is what was *meant*; the UTC slot is what got\n*executed* and stored. See\n[Requested vs. executed time](/docs/requested-vs-executed-time/) for the\nmechanics of that resolution and [Resolution and grain](/docs/resolution-and-grain/)\nfor choosing between UTC-indexed and local-indexed reporting up front.\n\n## Event, ingestion, and report time\n\nA single record commonly carries three distinct instants, and reports\nroutinely mix them without saying so:\n\n<SpecGrid rows={[\n  [\"event time\", \"When the thing actually happened \u2014 an impression served, a door opened, a purchase completed.\"],\n  [\"ingestion time\", \"When the record arrived in the measuring system's pipeline, which can lag event time by seconds (streaming) to days (batch).\"],\n  [\"report time\", \"When the aggregate containing the record was computed or published, which can lag ingestion further.\"],\n]} />\n\nEach of the three resolves to a different slot in general, and the gap\nbetween event time and ingestion time is rarely constant \u2014 batch\npipelines, retry queues, and offline device sync all introduce variable\nlag, so \"ingestion slot\" is not a fixed offset from \"event slot\" that can\nbe corrected after the fact with a single constant. Any slot-indexed\ndataset must declare which of the three timestamps defines the slot\n(`event-vs-ingestion-vs-report-time`); the default in this KB is event\ntime, but a system built on top of a feed that only reliably provides\ningestion time must say so explicitly rather than label the result \"event\nslot\" by convention.\n\n## Attribution-window time\n\nAttribution introduces a fourth candidate: when a conversion is credited\nto an earlier impression under a click-through or view-through attribution\nmodel, the \"time\" of the conversion event is ambiguous between the\nconversion's own timestamp and the timestamp of the impression it is\nattributed to (`attribution-window-time`). A conversion that happens at\n23:50 on Friday but is attributed to a Tuesday-morning impression could\nreasonably be slotted either as \"Friday night\" (event time) or \"Tuesday\nmorning\" (attributed time) \u2014 and a report that does not declare which it\nused cannot be reconciled against a second report using the other\nconvention, even though both reports are internally consistent. State\nwhether the slot is the conversion's own event time or the attributed\nimpression's time, and retain both fields rather than discarding one.\n\n## No silent rollup, and slot-boundary dedup\n\nTwo measurement-specific failure modes round out this page, both of which\nare about what happens *after* a correct slot has already been computed.\n[No-silent-temporal-rollup](/edge-cases/no-silent-temporal-rollup/):\na buyer who requests hourly delivery or reporting and receives a report\nsilently computed at day or week grain has been given an answer to a\ncoarser, unstated question \u2014 see\n[Resolution and grain](/docs/resolution-and-grain/) for the discipline\nthis requires at every grain choice in the pipeline, not only at the\nfinal report.\n[Slot-boundary dedup](/edge-cases/slot-boundary-dedup/): an event with\nduration \u2014 a session, a video view, a linear ad airing \u2014 that spans a\n:00 boundary can be double-counted (attributed to both slots it touches)\nor dropped (attributed to neither, if the assignment rule implicitly\nassumes instantaneous events) unless the assignment rule is declared:\nstart-time, end-time, or overlap-weighted apportionment, deduplicated by\n`(key, slot)` so a single spanning event contributes to a slot's count at\nmost once under whichever rule was chosen.\n\n## The core tension, stated plainly\n\nTwo platforms can both report \"traffic at slot 69, week 2026-W30\" and\nmean genuinely different things: one measured event time from its own\ningestion pipeline with a 4-hour batch lag folded in unnoticed; the other\nmeasured attributed conversions credited back to a click that happened in\na different slot entirely. The slot matching perfectly is not evidence\nthe measurements agree \u2014 it is only evidence that both systems can\ncompute the same arithmetic. Reconciling two slot-indexed datasets\nrequires reconciling the semantics behind the slot \u2014 which time defines\nit, whether it reflects UTC-canonical or local-experience intent, whether\nattribution shifted it \u2014 before the numbers themselves can be compared,\nexactly as the geo KB requires reconciling *what counts as inside a\npolygon* before two coverage numbers can be compared, not just agreeing on\nthe polygon's coordinates."
  },
  {
   "title": "Requested vs. Executed Time",
   "slug": "requested-vs-executed-time",
   "category": "concepts",
   "summary": "A local wall time is what was requested; the UTC slot is what was executed; a DST gap or fold means the two cannot both hold exactly, and the record must say so rather than silently pick one.",
   "source_geometry": [
    "local_datetime",
    "iana_zone"
   ],
   "destination_geometry": [
    "instant",
    "hour_of_week_slot"
   ],
   "status": "stable",
   "cell_systems": [],
   "edge_cases": [
    "spring-forward-gap",
    "fall-back-fold",
    "no-silent-temporal-rollup"
   ],
   "related": [
    "temporal-interoperability-model",
    "measurement-semantics"
   ],
   "badges": [],
   "last_reviewed": "2026-07-29",
   "body": "The geo-interoperability KB draws a hard line between what a buyer\n**requested** (a polygon, a radius, an address) and what a system\n**executed** (a set of H3 cells, a circumscribed circle). This page ports\nthat doctrine to time. A local wall time \u2014 \"8:00 PM in Chicago\" \u2014 is a\nrequest. The canonical UTC slot it resolves to is the execution. Most of\nthe time the two agree so cleanly that the distinction feels academic.\nTwice a year, for one hour each, they cannot both be honored exactly, and\nthat is precisely when a system's honesty is tested.\n\n## Why they can diverge\n\nDaylight saving time transitions create two structurally different\nfailure modes, both handled by `localToSlot` and surfaced end-to-end by\n`resolveLocalToCanonical` in `lib/time/`:\n\n**Spring-forward gap.** At the DST-start transition, clocks jump forward\nan hour and a whole range of wall times simply never occurs. `America/New_York`\nskips from 01:59:59 directly to 03:00:00 on 2026-03-08, so `02:30` that\nmorning is not a request that can be executed as-written \u2014 it names a\nmoment that does not exist on that clock. There is exactly one adjacent\nvalid instant (03:30 EDT, the same clock position measured forward), and\nthe system's only honest choices are: resolve forward to it, resolve\nbackward to the pre-gap instant, or reject the request outright.\n\n**Fall-back fold.** At the DST-end transition, clocks repeat an hour, so\na wall time names two different instants. `America/New_York` repeats\n01:00\u201301:59 on 2026-11-01, once at UTC-4 (EDT) and once at UTC-5 (EST).\n`01:30` that morning is ambiguous between two UTC instants an hour apart\n\u2014 and, because slots are hour granular, potentially two different slots.\n\n<Callout type=\"warning\" title=\"Never silently resolve a gap or fold\">\nA system that picks a resolution for a gap or fold without a declared\npolicy, and without recording that it did so, has quietly converted an\nambiguous or invalid request into a false-precision answer. The record\nmust carry `wasNonexistent` / `wasAmbiguous` and the policy applied \u2014\nnever just the resolved instant.\n</Callout>\n\n## The policy parameter\n\nBoth hazards are resolved by a declared `disambiguation` policy, not a\ndefault buried in a library:\n\n<SpecGrid rows={[\n  [\"earliest\", \"Fold: the first (pre-transition, larger-offset) occurrence. Gap: the single valid post-transition instant.\"],\n  [\"latest\", \"Fold: the second (post-transition, smaller-offset) occurrence. Gap: the same single valid instant as earliest (there is only one).\"],\n  [\"reject\", \"Throw rather than guess, for either hazard, when silent resolution is unacceptable.\"],\n]} />\n\nOther zone examples clarify the two hazards further: `05:30` in\n`Asia/Kolkata` never touches a DST transition at all \u2014 India has\nobserved a fixed +5:30 offset since 1945 \u2014 so it resolves unambiguously\nto `00:00 UTC`, which is slot 0. A zone inferred from geography (see\n`inferred-timezone-from-geo`) inherits whichever policy its wall time\nrequires only if the zone itself is correctly resolved first \u2014 a wrong\nzone produces a confidently wrong slot with no `wasAmbiguous` flag to\ncatch it, because the ambiguity was in the zone lookup, not the clock\narithmetic.\n\n## Worked example\n\n```ts\nimport { resolveLocalToCanonical } from \"@/lib/time/provenance\";\n\n// Fall-back fold: 2026-11-01 01:30 America/New_York occurs twice.\nconst earliest = resolveLocalToCanonical(\n  { year: 2026, month: 11, day: 1, hour: 1, minute: 30 },\n  \"America/New_York\",\n  \"earliest\",\n);\n// earliest.executed.utc        -> \"2026-11-01T05:30:00.000Z\"\n// earliest.executed.wasAmbiguous -> true\n// earliest.executed.lossless   -> false\n\nconst latest = resolveLocalToCanonical(\n  { year: 2026, month: 11, day: 1, hour: 1, minute: 30 },\n  \"America/New_York\",\n  \"latest\",\n);\n// latest.executed.utc          -> \"2026-11-01T06:30:00.000Z\"\n// same requested wall time, one hour and one slot apart\n\n// Spring-forward gap: 2026-03-08 02:30 America/New_York never occurs.\nconst gap = resolveLocalToCanonical(\n  { year: 2026, month: 3, day: 8, hour: 2, minute: 30 },\n  \"America/New_York\",\n  \"earliest\",\n);\n// gap.executed.utc             -> \"2026-03-08T07:30:00.000Z\"\n// gap.executed.wasNonexistent  -> true\n// gap.executed.lossless        -> false\n```\n\n`resolveLocalToCanonical` returns a `requested` object (the wall time,\nzone, grain, and disambiguation policy exactly as asked) and an `executed`\nobject (the resolved slot, week-slot key, UTC instant, offset, which\ndisambiguation was actually applied, and a `lossless` boolean that is\n`false` whenever `wasNonexistent` or `wasAmbiguous` is true). No field is\noverwritten or dropped \u2014 a caller who only reads `executed.slot` gets a\ncorrect answer; a caller who needs to know whether that answer required a\njudgment call reads `executed.lossless` and `provenance` alongside it.\n\n## Never silently rolled up\n\nThe same discipline extends past DST into grain: if a caller asks for\nhour-of-week slot delivery and the system can only report at a coarser\ngrain \u2014 day or week \u2014 that coarsening must be a declared, requested\noperation, never a silent default (`no-silent-temporal-rollup`). A report\nthat says \"daily\" when the caller asked for \"hourly\" has the same shape of\ndishonesty as a slot resolved from a fold without recording which\noccurrence was chosen: both replace an exact answer to the question asked\nwith an approximate answer to a different, unstated question. See\n[Resolution and grain](/docs/resolution-and-grain/) for the decision\nguide on choosing a grain up front, and\n[Timestamp to slot](/docs/timestamp-to-slot/) for the full conversion\nthis page's doctrine governs."
  },
  {
   "title": "Resolution and Grain",
   "slug": "resolution-and-grain",
   "category": "concepts",
   "summary": "Instant, hour-of-week slot, broadcast day, daypart, and ISO week trade off statistical power, privacy, and platform reporting constraints; coarsening is a declared choice, never a silent default.",
   "source_geometry": [
    "instant",
    "hour_of_week_slot"
   ],
   "destination_geometry": [
    "broadcast_day",
    "daypart",
    "iso_week"
   ],
   "status": "stable",
   "cell_systems": [],
   "edge_cases": [
    "timestamp-rounding-truncation",
    "no-silent-temporal-rollup"
   ],
   "related": [
    "the-168-axis",
    "measurement-semantics"
   ],
   "badges": [],
   "last_reviewed": "2026-07-29",
   "body": "Choosing a time grain is choosing how much of the underlying signal a\nmodel or report is allowed to see, and that choice should be made\ndeliberately, once, up front \u2014 not discovered after the fact because a\nplatform's export happened to round every timestamp to the day. This page\nlays out the grain ladder this KB works with and the considerations that\nshould drive a choice at any given rung.\n\n## The grain ladder\n\n<SpecGrid rows={[\n  [\"instant\", \"A single epoch millisecond value. Exact, but not directly comparable across weeks or aggregable without a coarser bucket.\"],\n  [\"hour_of_week_slot\", \"0-167, slot 0 = Mon 00:00 UTC. The canonical unit \u2014 see The 168 axis. Repeats weekly; pair with an ISO week for uniqueness.\"],\n  [\"broadcast_day\", \"A declared local cutover hour (commonly 06:00) defines when one day ends and the next begins; can be 23, 24, or 25 UTC hours across a DST transition.\"],\n  [\"daypart\", \"A named local band (morning, primetime) mapped to a UTC slot-set per zone and week; the reporting grain of media planning, not a modeling primitive.\"],\n  [\"iso_week (cell x week)\", \"The coarsest grain in this ladder: one row per (cell, week). The standard MMM grain \u2014 enough weeks of history for a stable regression, coarse enough to avoid daily noise.\"],\n]} />\n\nCoarser is not automatically safer, and finer is not automatically\nbetter: each rung trades away a specific kind of resolution to gain a\nspecific kind of stability, and the right choice depends on what the\ndownstream consumer is actually going to do with the number.\n\n## Choosing a grain\n\n**Statistical power.** A marketing-mix model regressing weekly spend\nagainst weekly outcome needs enough independent weekly observations to\nfit a stable coefficient \u2014 commonly 104+ weeks (two years) to resolve\nseasonality separately from a media effect. Daily or hourly grain for the\nsame regression multiplies the row count but does not multiply\nindependent information at the same rate, because adjacent hours within a\nday are highly autocorrelated; the extra rows buy resolution on\nwithin-week shape, not more independent evidence for the weekly\ncoefficient. A day-part causal test (does a 6pm-9pm flight lift traffic\nversus a 9pm-midnight flight) needs the opposite: fine enough grain\n(hour-of-week slot) that the two windows are actually distinguishable in\nthe data, because collapsing both into a single \"evening\" daypart erases\nthe very contrast the test is designed to detect.\n\n**Privacy.** Individual-level event timestamps at instant grain are\nhigher-risk for re-identification than the same events aggregated to a\nslot or a week, because a rare instant (a single visit at 3:14:07am) can\nbe a fingerprint in a way that \"visited during slot 3\" is not. Aggregating\nto a coarser grain before an inventory leaves controlled infrastructure is\na defensible privacy control \u2014 but it must be declared, not discovered\ndownstream by an analyst wondering why every hour looks identical within\na day.\n\n**Platform reporting grain.** Ad platforms, POS systems, and BI tools\neach report at their own native grain, and that grain is frequently\ncoarser than the canonical slot even when the underlying event stream is\nfiner \u2014 a POS system might expose \"daily transaction count\" with no\nhourly breakdown available at all. The platform's native grain caps the\nfinest grain any analysis built on that feed can honestly claim,\nregardless of what grain the modeling question would prefer.\n\n## Decision guide\n\n<SpecGrid rows={[\n  [\"Weekly MMM regression across many markets\", \"iso_week (cell x week) \u2014 coarse, stable, matches spend cadence\"],\n  [\"Day-part causal lift test (single market)\", \"hour_of_week_slot \u2014 fine enough to isolate the tested window\"],\n  [\"Cross-platform media plan reporting\", \"daypart, converted per zone/week to hour_of_week_slot for measurement\"],\n  [\"Broadcast/linear inventory and program scheduling\", \"broadcast_day, with a declared cutover hour\"],\n  [\"Any export leaving controlled infrastructure\", \"no finer than privacy policy allows; declare the aggregation applied\"],\n]} />\n\n<Callout type=\"danger\" title=\"No silent rollup\">\nIf a caller requests hour-of-week slot delivery and the system can only\nproduce day or week grain, that is a rejection or a renegotiation, not a\nquiet substitution. A report labeled \"hourly\" that is actually daily\nunder the hood is not a rounding error \u2014 it silently answers a different\nquestion than the one asked, and no downstream consumer can detect the\nsubstitution without re-deriving the grain from first principles.\n</Callout>\n\n## Truncation is a one-way door\n\nTimestamp rounding and truncation (`timestamp-rounding-truncation`) is the\ndata-quality version of the same problem: a timestamp stored with only\nday-level precision \u2014 common in older warehouses or privacy-truncated\nexports \u2014 cannot be placed in a slot at all, because the hour-of-week\ninformation was discarded before the record ever reached this pipeline.\nThere is no recovery step for this; the claimed grain of any downstream\nanalysis must be capped at the coarsest grain any input column actually\nsupports, and a system that reports slot-level granularity built on top\nof day-truncated inputs is fabricating precision it does not have. When\nin doubt, treat the finest grain any single input column supports as a\nhard ceiling on the finest grain the whole pipeline may claim, and state\nthat ceiling explicitly in any report \u2014 see\n[Measurement semantics](/docs/measurement-semantics/) for how this\ncompounds with event-vs-ingestion-vs-report time ambiguity, and\n[The 168 axis](/docs/the-168-axis/) for the canonical unit this ladder is\nbuilt around."
  },
  {
   "title": "Slot to UTC Window",
   "slug": "slot-to-utc-window",
   "category": "from-canonical",
   "summary": "A slot paired with an ISO week resolves deterministically to a one-hour UTC interval; reporting must honor the requested grain exactly or reject the request, never silently roll it up.",
   "source_geometry": [
    "hour_of_week_slot",
    "iso_week"
   ],
   "destination_geometry": [
    "utc_interval"
   ],
   "exactness": "exact",
   "status": "stable",
   "cell_systems": [],
   "edge_cases": [
    "no-silent-temporal-rollup",
    "leap-seconds"
   ],
   "related": [
    "the-168-axis",
    "iso-week"
   ],
   "badges": [],
   "last_reviewed": "2026-07-29",
   "body": "<Badge tone=\"exact\">exact</Badge>\n\nThis is the inverse of [Timestamp to slot](/docs/timestamp-to-slot/): given\na canonical (ISO week x slot) coordinate, recover the concrete UTC\ninterval it names. Unlike the forward conversion, this direction carries\nno DST ambiguity at all \u2014 it is pure, deterministic arithmetic over a\ntimeline that has no gaps or folds in it, because UTC itself does not\nobserve daylight saving time.\n\n## Purpose\n\nConvert a `hour_of_week_slot` (0\u2013167) plus an ISO week (`isoYear`,\n`isoWeek`) into the concrete `utc_interval` \u2014 a half-open `[start, end)`\nrange spanning exactly one UTC hour \u2014 that the slot names within that\nspecific week. This is the conversion that turns an abstract, repeating\ncoordinate back into a schedulable, queryable moment: \"run this campaign\nduring 2026-W30-S069\" only means something once it is resolved to\n`[2026-07-22T21:00:00Z, 2026-07-22T22:00:00Z)`.\n\n## Source and destination\n\n<SpecGrid rows={[\n  [\"source\", \"hour_of_week_slot (0-167) + iso_week (isoYear, isoWeek)\"],\n  [\"destination\", \"utc_interval: a half-open [start, end) UTC range, exactly one hour wide\"],\n  [\"exactness\", \"exact \u2014 pure arithmetic, no zone resolution, no DST hazard\"],\n  [\"params\", \"isoYear (number), isoWeek (1-53), slot (0-167, normalized if out of range)\"],\n  [\"outputs\", \"startUtc (epoch ms / ISO string), endUtc (startUtc + 3,600,000 ms)\"],\n  [\"units\", \"instants in epoch milliseconds; interval width is exactly one UTC hour (3,600,000 ms)\"],\n]} />\n\n## Algorithm\n\n```ts\nimport { isoWeekStartUtc } from \"@/lib/time/isoweek\";\nimport { slotToInstant, normalizeSlot } from \"@/lib/time/slot\";\n\nfunction slotToUtcWindow(\n  isoYear: number,\n  isoWeek: number,\n  slot: number,\n): { startUtc: string; endUtc: string; startMs: number; endMs: number } {\n  const weekStartMs = isoWeekStartUtc(isoYear, isoWeek); // Monday 00:00 UTC\n  const startMs = slotToInstant(weekStartMs, normalizeSlot(slot));\n  const endMs = startMs + 3_600_000; // exactly one UTC hour, always\n  return {\n    startUtc: new Date(startMs).toISOString(),\n    endUtc: new Date(endMs).toISOString(),\n    startMs,\n    endMs,\n  };\n}\n\n// The inverse of the worked example on \"The 168 axis\":\nconst window = slotToUtcWindow(2026, 30, 69);\n// window.startUtc -> \"2026-07-22T21:00:00.000Z\"\n// window.endUtc   -> \"2026-07-22T22:00:00.000Z\"\n```\n\n`isoWeekStartUtc` anchors the repeating slot to a specific week by\nresolving Monday 00:00 UTC of that ISO week; `slotToInstant` then adds\n`slot * 3,600,000` milliseconds. Because both steps operate purely in UTC\n\u2014 no zone lookup, no local calendar \u2014 the result is deterministic for\nevery valid `(isoYear, isoWeek, slot)` triple, and normalizing the slot\nwith `normalizeSlot` makes the function total over any integer input\nrather than throwing on an out-of-range value.\n\n## No-silent-rollup\n\n<Callout type=\"danger\" title=\"Report at the requested grain or reject\">\nIf a caller asks for the UTC window of a specific slot and the serving\nsystem can only resolve to a day or week boundary \u2014 for example, a\nreporting table that only stores daily rollups \u2014 the correct response is\nan explicit rejection or a renegotiated grain, never a silently widened\nwindow. A caller who asked for a one-hour window and received an\nundisclosed 24-hour window has been given an answer to a coarser question\nthan the one asked, and nothing in the response shape reveals that a\nsubstitution occurred.\n</Callout>\n\nThis mirrors the geo KB's no-silent-rollup rule for cell aggregation:\njust as a system must never quietly return an H3 R5 cell's centroid when\nan R8 point was requested, this conversion must never quietly return a\nday-level window when an hour-level slot was requested. See\n[Resolution and grain](/docs/resolution-and-grain/) for how to negotiate\ngrain up front so this situation is rare rather than a runtime surprise.\n\n## Quality and edge cases\n\nThe conversion is exact to the millisecond for any valid input, with one\nqualification: [leap seconds](/edge-cases/leap-seconds/). UTC has\ninserted 27 leap seconds since 1972 (with insertions expected to be\nphased out by around 2035), making an occasional UTC day 86,401 seconds\nrather than 86,400; cloud providers that \"smear\" the leap second across a\n24-hour window can disagree with strict UTC by up to roughly half a\nsecond during the smear. For hour-of-week bucketing this is immaterial \u2014\na half-second discrepancy never crosses an hour boundary \u2014 but a system\nperforming sub-second joins against this window (aligning a video frame\nor a bid-request timestamp to the hour boundary, say) should declare its\nclock model (UTC, TAI, or a specific smear algorithm) explicitly rather\nthan assume all \"UTC\" timestamps in a join are measured against the same\nclock.\n\n[No-silent-temporal-rollup](/edge-cases/no-silent-temporal-rollup/) is the\nsingle most consequential edge case for this conversion precisely because\nthe conversion itself has no failure mode of its own \u2014 the arithmetic is\nexact \u2014 so the entire risk surface sits in how the resulting window is\nreported downstream. Pair every UTC window this conversion returns with\nthe ISO week and slot it was derived from, so a consumer can always\nverify the window matches the grain it originally requested. See\n[The 168 axis](/docs/the-168-axis/) for the slot definition this\nconversion inverts, and [ISO week and the week-slot key](/docs/iso-week/)\nfor how `isoWeekStartUtc` anchors the week boundary this window is\ncomputed relative to."
  },
  {
   "title": "The Temporal Interoperability Model",
   "slug": "temporal-interoperability-model",
   "category": "concepts",
   "summary": "A timestamp with a zone is a request; the pipeline resolves it to a UTC instant, buckets it into a slot 0-167, and keys it to an ISO week before anything is measured or reported.",
   "source_geometry": [
    "timestamp",
    "iana_zone"
   ],
   "destination_geometry": [
    "hour_of_week_slot",
    "week_slot_key"
   ],
   "status": "stable",
   "cell_systems": [],
   "edge_cases": [
    "utc-canonical-vs-local-experience",
    "tzdb-vintage-mismatch"
   ],
   "related": [
    "the-168-axis",
    "requested-vs-executed-time",
    "timestamp-to-slot"
   ],
   "badges": [],
   "last_reviewed": "2026-07-29",
   "body": "Every temporal fact this knowledge base handles enters as a local\nobservation and leaves as a canonical coordinate. The path between those\ntwo states is fixed, ordered, and \u2014 this is the point of the KB \u2014 never\nskipped or collapsed. This page names the four stages and states why\ncollapsing them produces silent, systematic errors rather than obviously\nbroken output.\n\n## The four stages\n\n**1. Timestamp + zone (requested).** The raw input is a wall-clock\nreading \u2014 `2026-07-29 14:30` \u2014 paired with an IANA zone identifier,\n`America/Chicago`. Neither number nor string alone means anything; a wall\ntime without a zone is not a moment in time, it is a pattern that could\nmatch any of roughly forty distinct instants depending on which zone\nresolves it.\n\n**2. Resolve to a UTC instant (DST-correct).** The (wall, zone) pair is\nresolved through the IANA time zone database to a single point on the UTC\ntimeline \u2014 an epoch millisecond value. This step is where daylight saving\ntime lives: most wall times resolve to exactly one instant, but twice a\nyear a local clock either skips an hour (spring-forward gap, the wall time\nnever occurs) or repeats one (fall-back fold, the wall time occurs twice).\nBoth hazards are resolved by a declared policy, not a default \u2014 see\n[Requested vs. executed time](/docs/requested-vs-executed-time/).\n\n**3. Hour-of-week slot 0\u2013167.** The UTC instant is bucketed into one of\n168 hour-of-week slots, where slot 0 is Monday 00:00 UTC and the slot is a\npure function of the instant: `weekdayMon0 * 24 + hourUTC`. This is the\ncanonical unit of the whole KB \u2014 see [The 168 axis](/docs/the-168-axis/).\nA slot alone repeats every week; it identifies a position in the cycle,\nnot a moment.\n\n**4. (ISO week x slot) key.** Pairing the repeating slot with an ISO\n8601 week number produces a unique, sortable key \u2014 `2026-W29-S045` \u2014\nthat identifies exactly one hour, once, forever. This is the join key\nevery downstream table, model feature, and report is built on.\n\n**5. Executed / reported grain.** Only at the very last step does the\ncanonical key get rolled up or annotated for a specific consumer: a\nbroadcast day, a daypart label, a weekly aggregate for an MMM model. That\nrollup is a declared, requested operation \u2014 never a silent default \u2014 as\ncovered in [Resolution and grain](/docs/resolution-and-grain/).\n\n```mermaid\nflowchart LR\n    A[\"Local timestamp + IANA zone\n(REQUESTED)\"] --> B[\"Resolve to UTC instant\nDST gap/fold: declared policy\"]\n    B --> C[\"Hour-of-week slot 0-167\ninstantToSlot(epochMs)\"]\n    C --> D[\"ISO week x slot key\n2026-W29-S045\nweekSlotKey(epochMs)\"]\n    D --> E[\"Executed / reported grain\ninstant | slot | day | week\"]\n    style A fill:#334,stroke:#88a\n    style D fill:#343,stroke:#8a8\n```\n\n<Callout type=\"note\" title=\"Local time is a request; the UTC slot is the execution\">\nThis is the direct temporal analog of the geo KB's requested-vs-executed\ngeography doctrine. A buyer asks for \"8pm local in Chicago.\" The system\nexecutes a UTC instant. Those are two different facts about the same\nevent, and the record should carry both rather than pretending the second\nderives losslessly from the first.\n</Callout>\n\n## Why the stages must stay distinct\n\nCollapsing stages 1 and 3 \u2014 treating a local hour as if it were the slot\n\u2014 breaks the moment a dataset spans more than one time zone, because\n\"2pm\" in New York and \"2pm\" in Los Angeles are three slots apart. Collapsing\nstages 3 and 4 \u2014 reporting a bare slot without its ISO week \u2014 breaks the\nmoment a report spans more than one week, because slot 45 recurs 52 or 53\ntimes a year and a bare slot cannot distinguish this Tuesday from next\nTuesday. Collapsing stage 5 into stage 4 \u2014 silently rolling a time slot up\nto a day or week grain \u2014 breaks any consumer that asked for hourly\ndelivery and received a coarser one without being told, which is why\n`no-silent-temporal-rollup` is one of the two edge cases this page flags\nexplicitly.\n\nEach stage also has its own, non-overlapping failure mode, which is the\npractical reason to keep them separate in code and in schema rather than\nfusing them into one \"parse the timestamp\" function: stage 2 fails on DST\nambiguity, stage 3 fails on origin-convention mismatches (a Sunday-start\nweek is off by 24 slots), and stage 4 fails on ISO week-year boundary\nbugs. A pipeline that fuses all three into one opaque conversion cannot\nreport *which* stage produced a wrong answer.\n\n## Doctrine\n\nNate's doctrine from the geo side of this KB ports over unchanged: **the\nfree converter canonicalizes the key; everything indexed by it is the\nproduct.** A single (ISO week x slot) key is cheap to compute and free to\nexpose \u2014 the conversion in stages 1 through 4 above. What is valuable is\neverything built on top of that key once it is trustworthy: demand curves\nkeyed by slot, causal tests that hold the slot fixed across markets,\nattribution windows measured in slots, and audience models trained on\nslot-indexed features. The KB gives away the coordinate system for free\nand monetizes the models that are indexed by it \u2014 exactly as the geo KB\ngives away H3 cell math and monetizes the audience layer built on H3.\n\n## Edge cases affecting this page\n\nThe [UTC-canonical-vs-local-experience](/edge-cases/utc-canonical-vs-local-experience/)\ntension \u2014 that \"primetime\" is a local concept while the canonical unit is\nUTC \u2014 is the single most common source of misapplied comparisons across\nthis pipeline, and is discussed in full in\n[Measurement semantics](/docs/measurement-semantics/).\n[Tzdb-vintage mismatch](/edge-cases/tzdb-vintage-mismatch/) affects stage\n2 specifically: two systems on different IANA tz database releases can\nresolve the identical (wall, zone) pair to different UTC instants near a\nchanged transition, so the tz database version belongs in the provenance\nrecord alongside the resolved instant, not just in a changelog somewhere."
  },
  {
   "title": "Temporal Provenance",
   "slug": "temporal-provenance",
   "category": "provenance",
   "summary": "A timestamp is not one moment but a lifecycle of moments produced by a clock of a stated source, accuracy, and model \u2014 the time analog of geometry provenance.",
   "source_geometry": [
    "timestamp",
    "clock_metadata"
   ],
   "destination_geometry": [
    "temporal_provenance"
   ],
   "exactness": "exact",
   "status": "stable",
   "cell_systems": [],
   "edge_cases": [
    "timestamp-provenance",
    "monotonic-vs-wall-clock",
    "ntp-sync-state",
    "multiple-clock-authorities",
    "ai-inferred-timestamp",
    "vm-snapshot-rollback",
    "offline-replay"
   ],
   "related": [
    "requested-vs-executed-time",
    "time-uncertainty",
    "causal-time-vs-physical-time",
    "measurement-semantics"
   ],
   "badges": [],
   "last_reviewed": "2026-07-29",
   "body": "The geo KB treats **provenance** as a first-class object: a geometry carries its\nsource, CRS, and boundary vintage, and a conversion that loses them is broken.\nTime deserves the same treatment, and this is where temporal interoperability\nstops being calendaring and becomes a distributed-systems and AI-pipeline problem.\n\nA value like `2026-07-12T14:31:02Z` looks complete. It is not. It hides **which**\nclock produced it, **how accurate** that clock was, **what timescale** it is on,\nand **which stage** of a pipeline the instant refers to.\n\n## A timestamp is a lifecycle, not a moment\n\nThe same datum passes through many times, and any of them can be mistaken for the\ncanonical event time:\n\n<SpecGrid rows={[\n  [\"requestedTime\", \"What a caller asked for.\"],\n  [\"observedTime\", \"When the phenomenon happened \u2014 the sensor's world.\"],\n  [\"capturedTime\", \"When the device recorded it.\"],\n  [\"receivedTime\", \"When the ingestion endpoint got it.\"],\n  [\"ingestedTime\", \"When it entered the pipeline.\"],\n  [\"processedTime\", \"When it was transformed.\"],\n  [\"storedTime\", \"When it was persisted.\"],\n  [\"reportedTime\", \"When it was surfaced in a report.\"],\n]} />\n\nTwo systems can pick different stages as canonical from the same record and land\nin different hour-of-week slots. So the choice of canonical stage must travel with\nthe value, and the gap between `observedTime` and `storedTime` is the pipeline\nlatency \u2014 a real quantity, not a rounding error.\n\n## The clock is part of the value\n\n<SpecGrid rows={[\n  [\"source\", \"gps \u2248 20 ns \u00b7 ptp \u2248 1 \u00b5s \u00b7 ntp \u2248 1\u201310 ms \u00b7 cellular \u2248 100 ms \u00b7 manual \u2248 minutes \u00b7 monotonic \u00b7 inferred \u00b7 synthetic\"],\n  [\"accuracyMs\", \"1-sigma accuracy. It sets how wide the slot assignment's error bar is (see time uncertainty).\"],\n  [\"model\", \"utc \u00b7 tai \u00b7 gps \u00b7 smeared \u00b7 monotonic. A smeared clock (Google/AWS leap-second smear) disagrees with UTC by up to ~0.5 s.\"],\n  [\"synchronized\", \"NTP/PTP sync state. An unsynchronized clock can be minutes off while emitting valid-looking timestamps.\"],\n]} />\n\n<Callout type=\"danger\" title=\"A monotonic clock has no UTC\">\n`performance.now()` and `CLOCK_MONOTONIC` measure elapsed time from an arbitrary\norigin. They have no fixed epoch, so they cannot be converted to UTC or a slot at\nall. Mixing a monotonic reading into a wall-clock column silently corrupts every\nlatency and ordering computation downstream.\n</Callout>\n\n## The model, made checkable\n\nThe KB ships this as an executable type, not just prose. A `TemporalProvenance`\nrecord is validated: monotonic values are rejected as unmappable, a backwards\nlifecycle step is flagged (a VM snapshot restore, an offline replay, an NTP jump,\nor a corrected timestamp), the canonical stage must be present, and sub-second\ndigits from a coarse clock are flagged as false precision.\n\n```ts\nimport {\n  provenanceIssues,\n  canonicalInstant,\n  pipelineLatencyMs,\n  type TemporalProvenance,\n} from \"@/lib/time\";\n\nconst p: TemporalProvenance = {\n  observedTime: \"2026-07-12T14:31:02.000Z\",\n  capturedTime: \"2026-07-12T14:31:02.200Z\",\n  ingestedTime: \"2026-07-12T14:35:00.000Z\",\n  storedTime: \"2026-07-12T14:35:01.000Z\",\n  canonicalStage: \"observedTime\",\n  clock: { source: \"gps\", accuracyMs: 0.00002, model: \"utc\", synchronized: true },\n  conversion: { tzdbVersion: \"2026a\", disambiguation: \"none\", lossless: true },\n};\n\ncanonicalInstant(p); // epoch ms of observedTime, or null if monotonic/unmappable\npipelineLatencyMs(p); // storedTime \u2212 observedTime = 239_000 ms\nprovenanceIssues(p); // [] \u2014 clean; else out-of-order / monotonic / false-precision\n```\n\nThe Python parity uses the same lifecycle fields on a dataclass plus\n`zoneinfo` for the conversion metadata; the tested reference implementation is the\nTypeScript in `lib/time/temporal-provenance.ts`.\n\n## Why it matters now\n\nAI pipelines make this urgent. An [AI-inferred timestamp](/edge-cases/ai-inferred-timestamp/)\nis a model output, not an observation; a [synthetic event time](/edge-cases/synthetic-event-time/)\nmust never masquerade as measured; and when\n[multiple clock authorities disagree](/edge-cases/multiple-clock-authorities/)\nduring an outage, the record must say which one won. None of that fits in a single\nISO-8601 string.\n\nCarrying temporal provenance is what turns this knowledge base from a time-conversion\nreference into a temporal-interoperability standard for event streams, distributed\nsystems, and AI agents."
  },
  {
   "title": "The 168 Axis",
   "slug": "the-168-axis",
   "category": "concepts",
   "summary": "The hour-of-week slot, 0-167, is a deterministic function of the UTC instant alone; paired with an ISO week it becomes a unique coordinate, the time analog of an H3 cell.",
   "source_geometry": [
    "instant"
   ],
   "destination_geometry": [
    "hour_of_week_slot",
    "week_slot_key"
   ],
   "status": "stable",
   "cell_systems": [],
   "edge_cases": [
    "slot-origin-convention",
    "week-year-boundary"
   ],
   "related": [
    "timestamp-to-slot",
    "iso-week"
   ],
   "badges": [],
   "last_reviewed": "2026-07-29",
   "body": "The canonical unit of this knowledge base is the **hour-of-week slot**: an\ninteger from 0 to 167 identifying which of the 168 hours in a repeating\nweek an instant falls in. It is the single coordinate every conversion,\nmodel feature, and report in this KB is ultimately expressed against.\n\n## Definition\n\nSlot 0 is Monday 00:00 UTC. The slot of any UTC instant is computed as:\n\n$$\n\\text{slot} = (\\text{weekdayMon0} \\times 24) + \\text{hourUTC}, \\quad \\text{slot} \\in [0, 168)\n$$\n\nwhere `weekdayMon0` is 0 for Monday through 6 for Sunday, and `hourUTC` is\nthe UTC hour-of-day, 0 through 23. The formula takes only the UTC instant\nas input \u2014 no zone, no local calendar, no declared cutover. That is\ndeliberate: the slot is defined once, on the one timeline every system in\nthe world already agrees on, and every zone-aware complexity (which local\nhour this corresponds to in Tokyo versus Toronto) is pushed to a separate\nannotation layer rather than baked into the coordinate itself.\n\n<SpecGrid rows={[\n  [\"range\", \"0 to 167 inclusive, 168 total slots\"],\n  [\"origin\", \"slot 0 = Monday 00:00:00 UTC\"],\n  [\"input\", \"a single UTC instant (epoch milliseconds)\"],\n  [\"determinism\", \"pure function of the instant; no zone or policy parameter\"],\n  [\"period\", \"repeats every 7 days (604,800,000 ms)\"],\n  [\"uniqueness\", \"not unique alone; pair with an ISO week for a unique moment\"],\n]} />\n\n## A slot repeats; a (week, slot) pair does not\n\nSlot 45 identifies \"Wednesday, 21:00 UTC\" as a recurring position in the\nweekly cycle \u2014 this Wednesday, next Wednesday, and every Wednesday since\nthe epoch share slot 45. That repetition is the entire value of the\ncoordinate for demand modeling: it lets a Tuesday-lunch spike be compared\nweek over week without re-deriving \"Tuesday lunch\" from a calendar each\ntime. But repetition means slot 45 alone cannot answer \"when,\" only\n\"which position in the cycle.\" Uniqueness requires pairing the slot with\nan ISO 8601 week number and week-numbering year, producing the canonical\nkey format `2026-W29-S045` \u2014 see\n[ISO week and the week-slot key](/docs/iso-week/). The pairing is computed\nby `weekSlotKey`, which derives both the ISO week and the slot from the\nsame UTC instant, so the two halves of the key can never disagree about\nwhich timeline they were measured on.\n\n```ts\nimport { instantToSlot, slotToLabel } from \"@/lib/time/slot\";\nimport { weekSlotKey } from \"@/lib/time/isoweek\";\n\nconst epochMs = Date.UTC(2026, 6, 22, 21, 0, 0); // 2026-07-22T21:00:00Z, a Wednesday\n\nconst slot = instantToSlot(epochMs);\n// 69 \u2014 Wednesday is weekdayMon0=2, hourUTC=21 -> 2*24 + 21 = 69\n\nconsole.log(slotToLabel(slot));\n// \"Wed 21:00 UTC\" (label is derived from the slot, not recomputed from epochMs)\n\nconst key = weekSlotKey(epochMs);\n// { isoYear: 2026, isoWeek: 30, slot: 69, key: \"2026-W30-S069\" }\n```\n\n`instantToSlot` and `weekSlotKey` both derive the weekday/hour split from\nthe same UTC instant, which is the property that matters: two calls\nagainst the same epoch millisecond value, anywhere in the codebase,\nalways agree, because neither depends on the caller's local clock,\nlocale, or the platform's default time zone \u2014 only on the instant itself.\n\n## The H3-cell analogy\n\nThe slot is the time analog of an H3 cell in the geo-interoperability KB:\na deterministic, boundary-independent bucket that every instant (point)\nfalls into exactly once per period, computed from a fixed, declared\nconvention rather than from the observer's frame of reference. An H3 cell\ndoes not care which country's coastline drew the polygon it sits inside;\na slot does not care which local clock the observer used to describe the\nhour. Both are stable join keys precisely because they are decoupled from\nthe political and cultural boundaries \u2014 administrative or civil-time \u2014\nlaid over the same underlying continuum. And, just as an H3 cell needs a\nresolution parameter to be meaningful (R7 versus R8), a slot needs its\npairing convention (bare slot versus week-keyed slot) declared before\nbeing joined against another dataset \u2014 see\n[Resolution and grain](/docs/resolution-and-grain/) for when a coarser or\nfiner unit than the slot is the right choice.\n\n## Edge cases\n\n[Slot-origin convention](/edge-cases/slot-origin-convention/) is the most\ncommon integration bug: a system that assumes a Sunday-start week, or one\nthat anchors slot 0 to local midnight instead of UTC midnight, disagrees\nwith this KB's convention by a whole day (24 slots) or by the zone offset,\nrespectively \u2014 and the disagreement is silent until two slot-indexed\ndatasets are joined and every weekday looks shifted. Always declare the\norigin (Monday 00:00 UTC) explicitly when documenting or exporting a\nslot-indexed dataset, and convert incoming data from any other convention\non ingest rather than downstream.\n\n[Week-year boundary](/edge-cases/week-year-boundary/) affects the pairing,\nnot the slot itself: the ISO week-numbering year can differ from the\ncalendar year in late December and early January (for example,\n2027-01-01 falls in ISO week 2026-W53), so a slot must always be carried\nalongside both the ISO week number and the ISO week-year, never a bare\nweek number, or the pairing silently points at the wrong year's week 1.\nSee [ISO week and the week-slot key](/docs/iso-week/) for the full\nresolution."
  },
  {
   "title": "Time Uncertainty",
   "slug": "time-uncertainty",
   "category": "provenance",
   "summary": "An instant is a point estimate plus an error bar; near an hour boundary the \u00b1window straddles two slots, so the assignment is not unique \u2014 the temporal weighted crosswalk.",
   "source_geometry": [
    "instant",
    "clock_metadata"
   ],
   "destination_geometry": [
    "uncertain_instant",
    "slot_set"
   ],
   "exactness": "weighted",
   "status": "stable",
   "cell_systems": [],
   "edge_cases": [
    "timestamp-confidence-interval",
    "clock-accuracy-metadata",
    "false-precision",
    "sampling-window",
    "mixed-precision-dataset"
   ],
   "related": [
    "temporal-provenance",
    "the-168-axis",
    "resolution-and-grain"
   ],
   "badges": [],
   "last_reviewed": "2026-07-29",
   "body": "<Badge tone=\"weighted\">weighted</Badge>\n\nThe geo KB's core discipline is that **a lat/long is a location plus an error\nbar**, and a point is assigned to cells at the resolution matched to that error,\nnever snapped. Time has the exact same structure: a timestamp is an instant plus a\n\u00b1 window, and a slot assignment is only safe when the whole window falls inside one\nhour-of-week slot.\n\nInstead of storing\n\n```text\n2026-07-12T14:31:02Z\n```\n\nstore\n\n```text\n2026-07-12T14:31:02Z \u00b1200ms\n```\n\nThis is routine in astronomy, robotics, sensor fusion, autonomous vehicles, and\nincreasingly in AI pipelines, where a fix carries a stated accuracy.\n\n## The straddle\n\nThe canonical slot is a whole UTC hour. When the \u00b1 window crosses an hour boundary\nthe instant has **more than one candidate slot** \u2014 the direct temporal analog of a\ngeo cell straddling a boundary. Snapping to the point estimate throws away the fact\nthat the true slot is uncertain.\n\n<Callout type=\"warning\" title=\"Certain only when the window fits\">\nA \u00b120 ns GPS fix is certain: one slot. A \u00b1200 ms phone fix at 14:59:59.900 is not:\nit straddles 15:00 and belongs partly to two slots. Report both candidates and a\nconfidence \u2014 do not pretend to a single answer the clock could not give.\n</Callout>\n\n## The model, made checkable\n\n```ts\nimport {\n  fromClock,\n  candidateSlots,\n  slotIsCertain,\n  slotConfidence,\n} from \"@/lib/time\";\n\n// A \u00b1200 ms fix at 00:59:59.900 UTC straddles the 00:00 \u2192 01:00 boundary.\nconst u = { epochMs: Date.UTC(2026, 6, 27, 0, 59, 59, 900), plusMinusMs: 200 };\nslotIsCertain(u); // false\ncandidateSlots(u); // [0, 1] \u2014 both hour-of-week slots the window touches\nslotConfidence(u); // ~0.5 \u2014 fraction of the window in the point-estimate slot\n\n// Build the uncertainty straight from a clock's stated accuracy:\nconst g = fromClock(Date.UTC(2026, 6, 27, 0, 30), { source: \"gps\", accuracyMs: 0.00002, model: \"utc\" });\nslotIsCertain(g); // true \u2014 one slot\n```\n\nThe tested reference implementation is `lib/time/uncertainty.ts`. In Python the\nsame idea is a `(datetime, timedelta)` pair; enumerate the slots at `t - \u0394` and\n`t + \u0394` and every hour boundary between.\n\n## Relationship to grain and false precision\n\nUncertainty and [resolution/grain](/docs/resolution-and-grain/) are two sides of\none coin: never claim a slot finer than the clock supports. A nanosecond timestamp\nfrom a clock accurate to \u00b11 second is [false precision](/edge-cases/false-precision/) \u2014\nthe extra digits are noise that can flip a near-boundary assignment. Carry the\naccuracy so downstream code weights, rather than trusts, the point estimate.\n\nFor values that are intervals rather than points \u2014 a\n[one-minute average](/edge-cases/sampling-window/), a scrape window \u2014 the same\nmachinery applies: assign to slots by overlap, weighted, not to a single slot."
  },
  {
   "title": "Timestamp to Slot",
   "slug": "timestamp-to-slot",
   "category": "to-canonical",
   "summary": "A local timestamp and an IANA zone resolve through the tz database to a UTC instant, with DST gaps and folds handled by a declared policy, then bucket deterministically into an hour-of-week slot 0-167.",
   "source_geometry": [
    "timestamp",
    "iana_zone"
   ],
   "destination_geometry": [
    "hour_of_week_slot"
   ],
   "exactness": "policy_dependent",
   "status": "stable",
   "cell_systems": [],
   "edge_cases": [
    "spring-forward-gap",
    "fall-back-fold",
    "half-hour-offset-zones",
    "naive-datetime-no-zone",
    "ambiguous-zone-abbreviations",
    "tzdb-vintage-mismatch",
    "non-dst-region-inside-dst-country"
   ],
   "related": [
    "the-168-axis",
    "dst-handling",
    "timezone-database",
    "requested-vs-executed-time"
   ],
   "badges": [],
   "last_reviewed": "2026-07-29",
   "body": "<Badge tone=\"policy_dependent\">policy_dependent</Badge>\n\nThis is the core conversion of the entire knowledge base: everything else\n\u2014 ISO week keys, broadcast days, dayparts, MMM features \u2014 is built on top\nof the slot this page produces. Get it wrong and every downstream\naggregate inherits the error silently.\n\n## Purpose\n\nConvert a local wall-clock timestamp, paired with an IANA time zone\nidentifier, into the canonical hour-of-week slot (0\u2013167) it falls in. The\nconversion is exact arithmetic on a UTC instant, but reaching that instant\nrequires resolving the local time through the IANA time zone database\nfirst, and that resolution step is where policy \u2014 not just arithmetic \u2014\nenters.\n\n## Source and destination\n\nSource: `timestamp` (a plain calendar/clock reading \u2014 year, month, day,\nhour, minute, no offset attached) plus `iana_zone` (a canonical zone id\nsuch as `Asia/Kolkata`, never an abbreviation or a bare offset).\nDestination: `hour_of_week_slot`, an integer 0\u2013167.\n\n<SpecGrid rows={[\n  [\"exactness\", \"policy_dependent \u2014 exact once a disambiguation policy is declared; not exact without one\"],\n  [\"params\", \"wall (year, month, day, hour, minute), zone (IANA id), disambiguation (earliest | latest | reject)\"],\n  [\"outputs\", \"slot, epochMs, utc (ISO string), wasNonexistent, wasAmbiguous, disambiguationApplied, offsetMinutes\"],\n  [\"units\", \"instants in epoch milliseconds; zones as IANA identifiers; tz engine is luxon\"],\n  [\"convention\", \"slot 0 = Monday 00:00 UTC; weekdayMon0 * 24 + hourUTC\"],\n]} />\n\n## Algorithm\n\n```ts\nimport { localToSlot } from \"@/lib/time/slot\";\n\n// Ordinary case: no DST transition involved.\nconst kolkata = localToSlot(\n  { year: 2026, month: 1, day: 15, hour: 5, minute: 30 },\n  \"Asia/Kolkata\",\n  \"earliest\",\n);\n// kolkata.utc  -> \"2026-01-15T00:00:00.000Z\"\n// kolkata.slot -> 72  (2026-01-15 is a Thursday in UTC: weekdayMon0=3,\n//                      hourUTC=0 -> 3*24 + 0 = 72)\n// India has held a fixed +5:30 offset since 1945: no gap, no fold.\n\n// Spring-forward gap: 2026-03-08 02:30 in America/New_York never occurs\n// (clocks jump 01:59:59 -> 03:00:00). Only one valid instant is adjacent.\nconst gap = localToSlot(\n  { year: 2026, month: 3, day: 8, hour: 2, minute: 30 },\n  \"America/New_York\",\n  \"earliest\",\n);\n// gap.utc              -> \"2026-03-08T07:30:00.000Z\"\n// gap.wasNonexistent   -> true\n// gap.disambiguationApplied -> \"earliest\"\n\n// Fall-back fold: 2026-11-01 01:30 in America/New_York occurs twice, at\n// two different UTC offsets an hour apart.\nconst foldEarliest = localToSlot(\n  { year: 2026, month: 11, day: 1, hour: 1, minute: 30 },\n  \"America/New_York\",\n  \"earliest\",\n);\n// foldEarliest.utc -> \"2026-11-01T05:30:00.000Z\" (pre-transition, EDT, -240)\n\nconst foldLatest = localToSlot(\n  { year: 2026, month: 11, day: 1, hour: 1, minute: 30 },\n  \"America/New_York\",\n  \"latest\",\n);\n// foldLatest.utc -> \"2026-11-01T06:30:00.000Z\" (post-transition, EST, -300)\n// Same requested wall time, one UTC hour and typically one slot apart.\n\n// Reject policy: throw rather than silently pick a side.\ntry {\n  localToSlot(\n    { year: 2026, month: 11, day: 1, hour: 1, minute: 30 },\n    \"America/New_York\",\n    \"reject\",\n  );\n} catch (e) {\n  // \"Ambiguous local time (fall-back fold): ... occurs twice.\"\n}\n```\n\nThe Kolkata example is worth double-checking by hand: `2026-01-15 05:30`\nin `Asia/Kolkata` (a fixed UTC+5:30 offset, no DST) resolves to\n`2026-01-15T00:00:00.000Z`. That instant's UTC weekday is Thursday\n(`weekdayMon0 = 3`) at `hourUTC = 0`, giving `slot = 3*24 + 0 = 72`. A\n05:30 Kolkata reading only lands on slot 0 when the resolved UTC instant's\n*date* happens to be a Monday \u2014 the slot depends on the full UTC instant,\nnot on the local clock reading alone.\n\n## DST and disambiguation behavior\n\nThe gap and fold are detected structurally, not by a hardcoded transition\ncalendar: a gap is any wall time the tz database resolves to a different\nclock reading than requested (the time was skipped); a fold is any wall\ntime for which an hour before and an hour after share the identical wall\nclock reading at two different UTC offsets. Both are handled by the same\n`disambiguation` parameter \u2014 `earliest` (first/pre-transition\noccurrence for a fold; the single valid instant for a gap), `latest`\n(second/post-transition occurrence for a fold; the same single instant\nfor a gap, since only one exists), or `reject` (throw for either hazard).\nThe result always reports `wasNonexistent` and `wasAmbiguous`\nindependently of which policy was applied, so a caller can distinguish\n\"resolved cleanly\" from \"resolved by policy\" even when both return a\nslot.\n\n<Callout type=\"warning\" title=\"Half-hour and 45-minute offset zones\">\nZones like `Asia/Kolkata` (+5:30), `Asia/Kathmandu` (+5:45), and\n`Australia/Eucla` (+8:45) sit off the whole UTC hour. A single local clock\nhour in these zones straddles two UTC hour-of-week slots \u2014 see\n`half-hour-offset-zones` \u2014 so a local *band* (a daypart, a broadcast\nwindow) in these zones cannot be assigned a single slot the way a single\ninstant can; it needs the weighted-slot-set treatment covered in\n[Resolution and grain](/docs/resolution-and-grain/) and\n[Daypart to slots](/docs/daypart-to-slots/). This page's conversion\n(a single instant to a single slot) is unaffected: the instant always\nlands in exactly one slot regardless of the zone's offset.\n</Callout>\n\n## Quality and provenance\n\nEvery resolution should be reported with both a `requested` object (the\nwall time, zone, and disambiguation policy as given) and an `executed`\nobject (the resolved slot, UTC instant, offset, and whether a gap or fold\nwas encountered) \u2014 see\n[Requested vs. executed time](/docs/requested-vs-executed-time/) for the\nfull `resolveLocalToCanonical` shape. A resolution where\n`wasNonexistent || wasAmbiguous` is true should always be flagged\n`lossless: false` downstream: the requested wall time and the executed\ninstant do not correspond 1:1, even though a single slot was returned.\n\n## Python parity\n\nThe tested reference implementation in this KB is the TypeScript above,\nbacked by luxon. The Python stdlib equivalent uses `zoneinfo` (3.9+) and\n`datetime.fold`:\n\n```python\nfrom datetime import datetime\nfrom zoneinfo import ZoneInfo\n\ndef local_to_utc(year, month, day, hour, minute, zone_name, fold=0):\n    tz = ZoneInfo(zone_name)\n    # fold=0 selects the earlier occurrence of an ambiguous (folded) wall\n    # time; fold=1 selects the later occurrence. A nonexistent (gap) wall\n    # time is resolved forward by .astimezone() regardless of fold.\n    wall = datetime(year, month, day, hour, minute, tzinfo=tz, fold=fold)\n    return wall.astimezone(ZoneInfo(\"UTC\"))\n\n# Fall-back fold, earlier occurrence (EDT, -04:00):\nlocal_to_utc(2026, 11, 1, 1, 30, \"America/New_York\", fold=0)\n# 2026-11-01 05:30:00+00:00\n\n# Fall-back fold, later occurrence (EST, -05:00):\nlocal_to_utc(2026, 11, 1, 1, 30, \"America/New_York\", fold=1)\n# 2026-11-01 06:30:00+00:00\n```\n\n`fold` only disambiguates a repeated wall time; it has no effect on an\nordinary unambiguous timestamp and does not, by itself, resolve a\nspring-forward gap \u2014 `.astimezone()` always normalizes a nonexistent wall\ntime forward to the next valid instant, matching this KB's `earliest`\nand `latest` policies for the gap case (they coincide, since only one\nvalid instant exists).\n\n## Edge cases\n\n[Naive datetime, no zone](/edge-cases/naive-datetime-no-zone/) is the most\ncommon upstream failure: a stored `2026-03-08 02:30` with no offset and no\naccompanying zone column cannot be resolved at all \u2014 and that specific\nvalue does not even exist in `America/New_York`, compounding a missing\nzone with a genuine gap. Reject naive timestamps at ingest rather than\nassuming UTC or a default zone.\n[Ambiguous zone abbreviations](/edge-cases/ambiguous-zone-abbreviations/)\n(\"IST\" is India, Ireland, *or* Israel; \"CST\" is US Central, China, *or*\nCuba) and bare numeric offsets carry no DST rules at all, so they cannot\ndrive this conversion \u2014 require canonical IANA ids and reject\nabbreviations on ingest.\n[Non-DST region inside a DST country](/edge-cases/non-dst-region-inside-dst-country/)\n\u2014 Arizona observes no DST while the rest of US Mountain does \u2014 means a\ncountry or generic-region label is ambiguous for half the year; resolve\nby IANA zone (`America/Phoenix` vs. `America/Denver`), never by country.\n[Tzdb-vintage mismatch](/edge-cases/tzdb-vintage-mismatch/) means two\nsystems on different IANA database releases can resolve the identical\ninput to different instants near a recently changed transition \u2014 pin and\nrecord the tzdb version in provenance, and re-resolve affected wall times\nafter a database bump."
  },
  {
   "title": "Timezone Database",
   "slug": "timezone-database",
   "category": "systems",
   "summary": "The IANA tz database is the versioned source of truth for zone offsets and DST rules; an offset is not a zone, and the database itself changes roughly ten times a year.",
   "source_geometry": [
    "iana_zone"
   ],
   "destination_geometry": [
    "instant"
   ],
   "status": "stable",
   "cell_systems": [],
   "edge_cases": [
    "tzdb-vintage-mismatch",
    "ambiguous-zone-abbreviations",
    "offset-is-not-a-zone",
    "windows-vs-iana-ids",
    "historical-offset-changes",
    "political-change-short-notice"
   ],
   "related": [
    "timestamp-to-slot",
    "dst-handling"
   ],
   "badges": [],
   "last_reviewed": "2026-07-29",
   "body": "## Purpose\n\nEvery local-to-UTC conversion in this KB \u2014 [timestamp-to-slot](/docs/timestamp-to-slot/),\n[broadcast day](/docs/broadcast-day/), [daypart-to-slots](/docs/daypart-to-slots/) \u2014\nultimately depends on one shared resource: the **IANA time zone database**\n(also called tzdata or the Olson database). It is the versioned record of\nevery zone's current and historical UTC offset, DST start/end rules, and\ntransition history. This page states what the database actually is, why \"an\noffset\" and \"a zone\" are different things, and why the database's own version\nnumber is provenance data that belongs in every conversion's output.\n\n## Zones are Area/Location, not offsets\n\nAn IANA zone id has the form `Area/Location` \u2014 `America/New_York`,\n`Europe/London`, `Asia/Kolkata`, `Pacific/Auckland` \u2014 named after a\nrepresentative location, not a fixed offset. A zone id encodes a **complete\nhistory** of offsets and DST rules for that location, including every past\nchange, so that resolving a timestamp from 1985 or a projected timestamp from\n2030 both use the correct rules for that instant. This KB's tz engine is\nluxon, which reads its rules from the runtime's ICU/tzdata bundle.\n\n## An offset is not a zone\n\nStoring `\"UTC+2\"` instead of `\"Europe/Kyiv\"` looks equivalent for a single\ninstant but is not, because a fixed offset has no DST rule and no future.\n`Europe/Kyiv` observed UTC+2 in January and UTC+3 in July prior to Ukraine's\n2024 DST discontinuation; a system that persisted `\"UTC+2\"` at any point\nwould resolve every subsequent summer timestamp one hour wrong. The rule:\n**persist the zone id, derive the offset per instant** \u2014 never the reverse,\nand never treat an offset as a substitute for a zone in storage.\n\n```ts\nimport { DateTime } from \"luxon\";\n\n// WRONG: a fixed offset has no DST rule and silently drifts across a transition.\nconst bad = DateTime.fromObject({ year: 2026, month: 7, day: 15, hour: 9 }, { zone: \"UTC+2\" });\n\n// RIGHT: the IANA zone carries the correct offset for whichever date is given.\nconst good = DateTime.fromObject({ year: 2026, month: 7, day: 15, hour: 9 }, { zone: \"Europe/Kyiv\" });\n```\n\n## Abbreviations are ambiguous\n\nThree-letter zone abbreviations do not uniquely identify a zone: `IST` is\nIndia Standard Time, Irish Standard Time, or Israel Standard Time; `CST` is\nUS Central, China Standard Time, or Cuba Standard Time; `EST` is used by both\nthe US and parts of Australia. None of these carry DST rules, and several\ncollide across completely unrelated regions. This KB's\n[timestamp-to-slot](/docs/timestamp-to-slot/) conversion requires a canonical\nIANA zone id and rejects bare abbreviations or numeric offsets at the input\nboundary rather than guessing.\n\n## Windows zone ids are a different vocabulary\n\nWindows identifies zones by display name \u2014 `\"Eastern Standard Time\"`,\n`\"Pacific Standard Time\"` \u2014 which do not match IANA ids 1:1 and, confusingly,\nWindows' `\"Eastern Standard Time\"` actually covers the same DST-observing\nregion as IANA's `America/New_York`, not literally standard-time-only.\nTranslating between the two vocabularies requires the CLDR `windowsZones`\nmapping table (a many-to-one map, since several IANA zones can share one\nWindows display name); never attempt a string-similarity guess between them.\n\n## The database changes \u2014 pin and record the version\n\nThe tz database is not static: the IANA maintainers cut a new release roughly\nten times a year, almost always in response to a government changing an\noffset, DST rule, or zone boundary with real-world effective dates\n(historically: Lebanon's abrupt 2023 DST delay, Ramadan-linked DST pauses in\nEgypt and Morocco, Chile and Fiji adjusting DST windows). A political change\nfrequently arrives with only days of public notice, so there is always a\nwindow where the deployed tzdb has not yet caught up to reality \u2014 an\nunavoidable lag, not a bug, but one that must be surfaced rather than hidden.\nTwo systems pinned to **different tzdb releases** can resolve the identical\n(wall time, zone) pair to two different UTC instants near any changed\ntransition \u2014 the direct temporal analog of a stale administrative-boundary\nvintage in the geo KB. The mitigation is the same pattern used throughout\nthis KB's provenance model: record the tzdb version actually used\n(`ConversionProvenance.tzdbVersion` in `lib/time/provenance.ts`) on every\nconversion, and re-resolve any wall time near a known transition once the\nruntime's tzdb is bumped.\n\n## Historical offsets are not today's offset\n\nZones changed their base offset long before modern DST existed, and some\nstill do: Samoa moved its date-line side in 2011, skipping December 30\nentirely to switch from UTC-11 to UTC+13; Venezuela shifted by 30 minutes in\n2007 and reverted in 2016; North Korea briefly ran 30 minutes off its\nneighbors from 2015-2018. A conversion for a historical instant must use the\noffset that was actually in force **then**, not the zone's current offset \u2014\nluxon (via the full tzdata history) resolves this correctly by construction\nas long as the zone id, not a cached offset, is what was stored.\n\n## Edge cases\n\n[Tzdb vintage mismatch](/edge-cases/tzdb-vintage-mismatch/),\n[political change with short notice](/edge-cases/political-change-short-notice/),\n[ambiguous zone abbreviations](/edge-cases/ambiguous-zone-abbreviations/),\n[offset is not a zone](/edge-cases/offset-is-not-a-zone/),\n[Windows vs IANA ids](/edge-cases/windows-vs-iana-ids/), and\n[historical offset changes](/edge-cases/historical-offset-changes/) are all\ndirect instances of the hazards above; see\n[Timestamp to Slot](/docs/timestamp-to-slot/) for how they surface in the\ncore local-to-UTC conversion, and [DST Handling](/docs/dst-handling/) for the\ngap/fold behavior the database's DST rules produce.\n\n## Python parity\n\nPython's standard library `zoneinfo` module (3.9+) reads the same IANA tz\ndatabase \u2014 either from the operating system's copy or, if absent, from the\n`tzdata` PyPI package \u2014 so a correctly configured Python runtime resolves\nzone ids identically to luxon, provided both are running the same tzdb\nrelease. Checking that release:\n\n```python\nimport zoneinfo\nprint(zoneinfo.TZPATH)         # where the OS/package tzdata is being read from\n```\n\nThere is no cross-language guarantee of matching tzdb versions without\nexplicit alignment \u2014 pin `tzdata` to the same release the Node/luxon runtime\nuses if exact cross-system agreement near a recent transition matters."
  },
  {
   "title": "Week Systems",
   "slug": "week-systems",
   "category": "systems",
   "summary": "ISO, US/retail, broadcast (Nielsen), and Middle-East week conventions disagree on the start day and on how weeks roll into months and years, so a bare week number is meaningless without its system.",
   "source_geometry": [
    "instant"
   ],
   "destination_geometry": [
    "iso_week"
   ],
   "status": "stable",
   "cell_systems": [],
   "edge_cases": [
    "week-numbering-systems",
    "broadcast-calendar-month",
    "week-year-boundary",
    "fifty-three-week-years"
   ],
   "related": [
    "iso-week",
    "broadcast-day"
   ],
   "badges": [],
   "last_reviewed": "2026-07-29",
   "body": "## Purpose\n\n\"Week 29\" means at least four different things depending on which week\nsystem produced the number. This page catalogs the competing conventions\nthis KB has to interoperate with, states which one it standardizes on, and\ngives the crosswalk logic for translating between them.\n\n## The competing systems\n\n<SpecGrid rows={[\n  [\"ISO-8601\", \"Monday start. Week 1 is the week containing the year's first Thursday (equivalently, the week containing January 4). Years have 52 or 53 weeks. Used by: this KB, most of Europe, ISO-conformant systems generally.\"],\n  [\"US / retail (NRF 4-5-4)\", \"Sunday start. The National Retail Federation's 4-5-4 fiscal calendar groups weeks into quarters of 4, 5, and 4 weeks (13 weeks/quarter), with its own fiscal year start (often the Sunday closest to Jan 31 or Feb 1), not the Gregorian calendar.\"],\n  [\"Broadcast / Nielsen\", \"Monday start, like ISO, but organized into its own broadcast month and quarter \u2014 a broadcast month is a whole number of broadcast weeks (typically 4 or 5) and does not align to the Gregorian month; a broadcast quarter runs 13 or 14 weeks. See broadcast-calendar-month below.\"],\n  [\"Middle East\", \"Common convention starts the week on Saturday (some countries: Sunday), reflecting a Friday-Saturday or Friday-only weekend. Week numbering under this convention does not agree with ISO week boundaries.\"],\n]} />\n\nA single instant can therefore carry a different week number under each\nsystem, and even systems that agree on the start day (ISO and broadcast both\nstart Monday) can still disagree on which week is \"week 1\" of the year,\nbecause their year boundaries and roll-up rules differ.\n\n## This KB's standard: ISO for the canonical key\n\nThis knowledge base standardizes on **ISO-8601** for the `(isoYear, isoWeek,\nslot)` key used everywhere \u2014 see [ISO Week](/docs/iso-week/) and\n[The 168 Axis](/docs/the-168-axis/). ISO was chosen because it is\nparameter-free and exact (no declared cutover, no fiscal-year anchor to\nconfigure) and because its Monday start aligns naturally with slot 0 =\nMonday 00:00 UTC. Any other week system a downstream platform reports in\n(US/retail, broadcast) is treated as a **presentation crosswalk** applied on\ntop of the canonical ISO key, not as an alternate canonical grain.\n\n## Crosswalk logic\n\n```ts\nimport { isoWeekOf } from \"@/lib/time/isoweek\";\nimport { DateTime } from \"luxon\";\n\n// ISO week (this KB's canonical): Monday start.\nconst iso = isoWeekOf(Date.parse(\"2026-07-19T00:00:00Z\")); // a Sunday\n// -> { isoYear: 2026, isoWeek: 29, isoWeekday: 7 }\n\n// US/retail week number (Sunday start) for the SAME instant requires a\n// different anchor rule entirely \u2014 it is not a fixed offset from the ISO\n// week, because the two systems' \"week 1\" definitions diverge independently\n// each year. A retail crosswalk must be computed against the retailer's own\n// declared fiscal calendar, not derived from the ISO week number.\nconst dt = DateTime.fromMillis(Date.parse(\"2026-07-19T00:00:00Z\"), { zone: \"utc\" });\nconst sundayStartWeekday = dt.weekday % 7; // 0 = Sunday ... 6 = Saturday\n```\n\nThe critical point the snippet makes explicit: because ISO and US/retail\nweeks can start their **year** in different places (ISO week 1 anchors to\nthe first Thursday; a retail fiscal year anchors to a declared date near\nmonth-end), there is no universal arithmetic formula converting an ISO week\nnumber directly into a retail week number \u2014 the retailer's specific fiscal\ncalendar (its declared year-start date) must be consulted. The only safe\ngeneral crosswalk is instant-based: resolve the target instant, then apply\neach system's own rule to that instant independently, rather than\ntransforming one week number into another.\n\n## Why a bare week number is a bug\n\n<Callout type=\"warning\" title=\"Always declare the system\">\nA payload containing only `\"week\": 29` cannot be interpreted correctly by\nany receiving system without also knowing which week system produced it \u2014\nISO week 29 of 2026 (Jul 13-19), a US/retail week 29 (a different date\nrange, anchored to that retailer's fiscal year start), and a broadcast week\n29 (aligned to Nielsen's broadcast calendar) are three different seven-day\nspans that happen to share a number. Always pair a week number with its\nsystem, and prefer the full `(isoYear, isoWeek)` pair \u2014 or the combined\n`week_slot_key` \u2014 as the canonical join key.\n</Callout>\n\n## Edge cases\n\n[Week-numbering systems](/edge-cases/week-numbering-systems/) is this page's\ncore subject. [Broadcast calendar month](/edge-cases/broadcast-calendar-month/):\nbecause a broadcast month is a whole number of broadcast weeks rather than a\nGregorian month, reconciling broadcast-month reporting against calendar-month\nreporting requires a declared week-to-month crosswalk, not a date-range\nassumption \u2014 see [Broadcast Day](/docs/broadcast-day/) for the related\nper-day convention. [Week-year boundary](/edge-cases/week-year-boundary/) and\n[fifty-three-week years](/edge-cases/fifty-three-week-years/) are properties\nof the ISO system specifically and are detailed on the\n[ISO Week](/docs/iso-week/) page.\n\n## Python parity\n\n```python\nfrom datetime import datetime, timezone\n\ndef iso_week(epoch_ms: int) -> tuple[int, int, int]:\n    return datetime.fromtimestamp(epoch_ms / 1000, tz=timezone.utc).isocalendar()\n\ndef sunday_start_weekday(epoch_ms: int) -> int:\n    # 0 = Sunday ... 6 = Saturday, for building a US/retail-style crosswalk.\n    dt = datetime.fromtimestamp(epoch_ms / 1000, tz=timezone.utc)\n    return (dt.isoweekday()) % 7\n```\n\nPython's `isocalendar()` gives the ISO figures natively; a US/retail or\nbroadcast crosswalk still requires each system's own declared calendar (a\nretailer's 4-5-4 fiscal year, or Nielsen's broadcast calendar) as external\ninput \u2014 no standard library or package encodes those rules generically,\nsince they are business conventions, not international standards."
  }
 ],
 "edgeCases": [
  {
   "id": "half-hour-offset-zones",
   "name": "Half-hour offset zones",
   "category": "offset",
   "description": "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.",
   "detectionMethod": "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 \u2014 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."
   ],
   "affectedConversions": [
    "daypart-slots",
    "broadcast-day",
    "local-to-slot"
   ],
   "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."
  },
  {
   "id": "forty-five-minute-offset-zones",
   "name": "45-minute offset zones",
   "category": "offset",
   "description": "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.",
   "detectionMethod": "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."
   ],
   "affectedConversions": [
    "daypart-slots",
    "broadcast-day"
   ]
  },
  {
   "id": "sub-hour-band-straddle",
   "name": "Sub-hour band straddle (general)",
   "category": "offset",
   "description": "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 \u2014 from sub-hour offsets, from bands defined at :30 (e.g. daytime 09:30\u201316:30), or from DST. This is the same phenomenon as a geo cell straddling a boundary.",
   "detectionMethod": "(localBandEdgeMinute + zoneOffsetMinutes) % 60 !== 0.",
   "mitigation": [
    "Model the band as a weighted slot-set (fraction of the hour in each slot).",
    "Pick one rule \u2014 overlap-weighted, majority, or edge-inclusive \u2014 and record it."
   ],
   "affectedConversions": [
    "daypart-slots",
    "broadcast-day"
   ]
  },
  {
   "id": "spring-forward-gap",
   "name": "Spring-forward gap (nonexistent local time)",
   "category": "dst",
   "description": "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.",
   "detectionMethod": "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)."
   ],
   "affectedConversions": [
    "local-to-slot",
    "broadcast-day",
    "daypart-slots"
   ]
  },
  {
   "id": "fall-back-fold",
   "name": "Fall-back fold (ambiguous local time)",
   "category": "dst",
   "description": "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 \u2014 two different UTC instants, two different slots.",
   "detectionMethod": "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."
   ],
   "affectedConversions": [
    "local-to-slot",
    "broadcast-day",
    "daypart-slots"
   ]
  },
  {
   "id": "partial-hour-dst-shift",
   "name": "Partial-hour DST shift",
   "category": "dst",
   "description": "Not every DST change is one hour. Lord Howe Island shifts by 30 minutes (+10:30 \u2194 +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.",
   "detectionMethod": "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 \u2014 apportion accordingly."
   ],
   "affectedConversions": [
    "local-to-slot",
    "broadcast-day"
   ]
  },
  {
   "id": "dst-transition-time-varies",
   "name": "DST transition time and date vary by zone",
   "category": "dst",
   "description": "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.",
   "detectionMethod": "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."
   ],
   "affectedConversions": [
    "local-to-slot",
    "broadcast-day"
   ]
  },
  {
   "id": "southern-hemisphere-reversed-dst",
   "name": "Reversed (Southern Hemisphere) DST",
   "category": "dst",
   "description": "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.",
   "detectionMethod": "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."
   ],
   "affectedConversions": [
    "daypart-slots",
    "semantics"
   ]
  },
  {
   "id": "non-dst-region-inside-dst-country",
   "name": "Non-DST region inside a DST country",
   "category": "dst",
   "description": "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.",
   "detectionMethod": "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."
   ],
   "affectedConversions": [
    "local-to-slot"
   ]
  },
  {
   "id": "tzdb-vintage-mismatch",
   "name": "Timezone-database vintage mismatch",
   "category": "tzdb",
   "description": "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 \u2014 the temporal analog of a boundary-vintage mismatch in geo.",
   "detectionMethod": "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."
   ],
   "affectedConversions": [
    "local-to-slot",
    "broadcast-day",
    "daypart-slots"
   ]
  },
  {
   "id": "political-change-short-notice",
   "name": "Political time change with short notice",
   "category": "tzdb",
   "description": "Governments change offsets or DST with days of notice \u2014 Lebanon (2023), Egypt and Morocco (Ramadan DST), Samoa, Venezuela. The tzdb lags real life, so systems disagree for a window until they update.",
   "detectionMethod": "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."
   ],
   "affectedConversions": [
    "local-to-slot"
   ]
  },
  {
   "id": "ambiguous-zone-abbreviations",
   "name": "Ambiguous zone abbreviations and bare offsets",
   "category": "tzdb",
   "description": "'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.",
   "detectionMethod": "Input is an abbreviation or a numeric offset rather than an IANA zone id.",
   "mitigation": [
    "Require canonical IANA zone ids (Area/Location). Reject abbreviations."
   ],
   "affectedConversions": [
    "local-to-slot"
   ]
  },
  {
   "id": "offset-is-not-a-zone",
   "name": "An offset is not a zone",
   "category": "tzdb",
   "description": "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.",
   "detectionMethod": "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."
   ],
   "affectedConversions": [
    "local-to-slot"
   ]
  },
  {
   "id": "windows-vs-iana-ids",
   "name": "Windows vs IANA zone ids",
   "category": "tzdb",
   "description": "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.",
   "detectionMethod": "Zone id is a Windows display name, not an IANA id.",
   "mitigation": [
    "Normalize through CLDR windowsZones before resolving."
   ],
   "affectedConversions": [
    "local-to-slot"
   ]
  },
  {
   "id": "historical-offset-changes",
   "name": "Historical offset changes",
   "category": "tzdb",
   "description": "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.",
   "detectionMethod": "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."
   ],
   "affectedConversions": [
    "local-to-slot"
   ]
  },
  {
   "id": "week-numbering-systems",
   "name": "Competing week-numbering systems",
   "category": "week",
   "description": "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.",
   "detectionMethod": "A week number arrives without its numbering system declared.",
   "mitigation": [
    "Declare the week system; default to ISO-8601; provide crosswalks to US/broadcast."
   ],
   "affectedConversions": [
    "iso-week"
   ]
  },
  {
   "id": "week-year-boundary",
   "name": "Week-year \u2260 calendar year",
   "category": "week",
   "description": "The ISO week-numbering year can differ from the calendar year around January 1 \u2014 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.",
   "detectionMethod": "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."
   ],
   "affectedConversions": [
    "iso-week"
   ]
  },
  {
   "id": "fifty-three-week-years",
   "name": "53-week years",
   "category": "week",
   "description": "Some ISO years have 53 weeks (when Jan 1 is Thursday, or a leap year starts on Wednesday \u2014 e.g. 2026). Code that assumes 52 weeks misaligns year-over-year comparisons and drops a week.",
   "detectionMethod": "weeksInWeekYear === 53.",
   "mitigation": [
    "Handle 53-week years explicitly; align year-over-year by week-year, not a fixed 52-offset."
   ],
   "affectedConversions": [
    "iso-week"
   ]
  },
  {
   "id": "broadcast-calendar-month",
   "name": "Broadcast (Nielsen) calendar month and quarter",
   "category": "week",
   "description": "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.",
   "detectionMethod": "Reporting mixes broadcast-month and calendar-month grains.",
   "mitigation": [
    "Declare which calendar; provide a broadcast\u2194calendar week crosswalk."
   ],
   "affectedConversions": [
    "iso-week",
    "broadcast-day"
   ]
  },
  {
   "id": "slot-origin-convention",
   "name": "Slot-origin convention",
   "category": "week",
   "description": "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.",
   "detectionMethod": "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."
   ],
   "affectedConversions": [
    "local-to-slot",
    "iso-week"
   ]
  },
  {
   "id": "broadcast-day-cutover-varies",
   "name": "Broadcast-day cutover is not universal",
   "category": "week",
   "description": "The broadcast day does not always start at 06:00 local \u2014 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.",
   "detectionMethod": "The cutover hour is not declared on the request.",
   "mitigation": [
    "Declare the cutover and echo it on every result (requested vs executed)."
   ],
   "affectedConversions": [
    "broadcast-day"
   ]
  },
  {
   "id": "naive-datetime-no-zone",
   "name": "Naive datetime with no zone",
   "category": "data_quality",
   "description": "A stored '2026-03-08 02:30' with no offset and no accompanying zone cannot be resolved to a slot \u2014 and, unluckily, that particular value does not even exist in US Eastern. Zone-less local timestamps are the most common warehouse hazard.",
   "detectionMethod": "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."
   ],
   "affectedConversions": [
    "local-to-slot"
   ]
  },
  {
   "id": "local-read-as-utc",
   "name": "Local timestamps read as UTC (or vice versa)",
   "category": "data_quality",
   "description": "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 \u2014 the diurnal curve peaks at the wrong hour.",
   "detectionMethod": "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."
   ],
   "affectedConversions": [
    "local-to-slot"
   ]
  },
  {
   "id": "epoch-and-unit-confusion",
   "name": "Epoch and unit confusion",
   "category": "instant",
   "description": "Seconds vs milliseconds vs microseconds, and non-Unix epochs (NTP 1900, Apple 2001, Windows FILETIME 1601), place events off by 1000\u00d7 or in the wrong century \u2014 a whole dataset lands in one slot or in 1970.",
   "detectionMethod": "Implausible resolved year (1970 clustering, 1601, or far future).",
   "mitigation": [
    "Assert the unit and epoch on ingest; range-check to a plausible window."
   ],
   "affectedConversions": [
    "local-to-slot"
   ]
  },
  {
   "id": "clock-skew-and-sentinels",
   "name": "Clock skew and sentinel timestamps",
   "category": "data_quality",
   "description": "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.",
   "detectionMethod": "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."
   ],
   "affectedConversions": [
    "local-to-slot"
   ]
  },
  {
   "id": "timestamp-rounding-truncation",
   "name": "Timestamp rounding / truncation",
   "category": "data_quality",
   "description": "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.",
   "detectionMethod": "Zero variance below the day or hour grain.",
   "mitigation": [
    "Cap the claimed grain to the truncation; do not report finer than the data supports."
   ],
   "affectedConversions": [
    "local-to-slot",
    "semantics"
   ]
  },
  {
   "id": "leap-seconds",
   "name": "Leap seconds",
   "category": "instant",
   "description": "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.)",
   "detectionMethod": "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 \u2014 declare it negligible rather than silent."
   ],
   "affectedConversions": [
    "local-to-slot",
    "slot-to-window"
   ]
  },
  {
   "id": "date-line-weekday-divergence",
   "name": "Date-line weekday divergence",
   "category": "dateline",
   "description": "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 \u2014 the temporal analog of the geo antimeridian.",
   "detectionMethod": "|zone offset| approaches or exceeds 12 hours.",
   "mitigation": [
    "Keep the canonical slot on UTC; annotate the local weekday separately; expect local-week \u2260 UTC-week near the line."
   ],
   "affectedConversions": [
    "local-to-slot",
    "iso-week",
    "semantics"
   ]
  },
  {
   "id": "extreme-offset-span",
   "name": "Extreme offset span (UTC\u221212 \u2026 +14)",
   "category": "dateline",
   "description": "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.",
   "detectionMethod": "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."
   ],
   "affectedConversions": [
    "daypart-slots",
    "semantics"
   ]
  },
  {
   "id": "utc-canonical-vs-local-experience",
   "name": "UTC canonical vs local experience",
   "category": "semantics",
   "description": "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 \u2014 the core temporal-interop tension, and the direct analog of geo's requested-vs-executed geography.",
   "detectionMethod": "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."
   ],
   "affectedConversions": [
    "daypart-slots",
    "semantics"
   ]
  },
  {
   "id": "event-vs-ingestion-vs-report-time",
   "name": "Event vs ingestion vs report time",
   "category": "semantics",
   "description": "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.",
   "detectionMethod": "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."
   ],
   "affectedConversions": [
    "local-to-slot",
    "semantics"
   ]
  },
  {
   "id": "attribution-window-time",
   "name": "Attribution-window time",
   "category": "measurement",
   "description": "A conversion is credited to an earlier impression, so the 'slot' of a conversion depends on the attribution model \u2014 the conversion's own time, or the attributed impression's time.",
   "detectionMethod": "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."
   ],
   "affectedConversions": [
    "semantics"
   ]
  },
  {
   "id": "no-silent-temporal-rollup",
   "name": "No silent temporal rollup",
   "category": "measurement",
   "description": "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.",
   "detectionMethod": "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."
   ],
   "affectedConversions": [
    "slot-to-window",
    "semantics"
   ]
  },
  {
   "id": "slot-boundary-dedup",
   "name": "Slot-boundary dedup and double-count",
   "category": "measurement",
   "description": "An event, session, or airing that spans a slot boundary can be counted in two slots or dropped \u2014 the temporal analog of geo's touching-only / duplicate eligibility.",
   "detectionMethod": "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)."
   ],
   "affectedConversions": [
    "local-to-slot",
    "semantics"
   ]
  },
  {
   "id": "inferred-timezone-from-geo",
   "name": "Timezone inferred from geography",
   "category": "data_quality",
   "description": "Bidstream and sensor data often lack a reliable device timezone, so it is inferred from a lat/long via a timezone-boundary shapefile \u2014 a GEO \u00d7 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.",
   "detectionMethod": "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\u2192cell assignment to its accuracy radius."
   ],
   "affectedConversions": [
    "daypart-slots",
    "local-to-slot"
   ]
  },
  {
   "id": "substitute-day-holidays",
   "name": "Substitute (in-lieu) holiday days",
   "category": "week",
   "description": "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.",
   "detectionMethod": "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."
   ],
   "affectedConversions": [
    "holidays"
   ]
  },
  {
   "id": "movable-and-regional-holidays",
   "name": "Movable and regional holidays",
   "category": "week",
   "description": "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.",
   "detectionMethod": "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."
   ],
   "affectedConversions": [
    "holidays"
   ]
  },
  {
   "id": "monotonic-vs-wall-clock",
   "name": "Monotonic vs wall clock",
   "category": "clock",
   "description": "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.",
   "detectionMethod": "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."
   ],
   "affectedConversions": [
    "local-to-slot"
   ],
   "page": "temporal-provenance"
  },
  {
   "id": "timestamp-provenance",
   "name": "Timestamp provenance (which time became canonical)",
   "category": "clock",
   "description": "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.",
   "detectionMethod": "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."
   ],
   "affectedConversions": [
    "local-to-slot",
    "iso-week"
   ],
   "page": "temporal-provenance"
  },
  {
   "id": "clock-accuracy-metadata",
   "name": "Clock-accuracy metadata",
   "category": "clock",
   "description": "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.",
   "detectionMethod": "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."
   ],
   "affectedConversions": [
    "local-to-slot"
   ],
   "page": "time-uncertainty"
  },
  {
   "id": "timestamp-confidence-interval",
   "name": "Timestamp confidence interval",
   "category": "clock",
   "description": "An instant is a point estimate plus an error bar \u2014 12:03:10 \u00b1150 ms, not a single moment. Near an hour boundary the \u00b1window straddles two slots, so the assignment is not unique (the temporal weighted crosswalk).",
   "detectionMethod": "The \u00b1window around the estimate crosses a slot boundary.",
   "mitigation": [
    "Represent the instant as (epoch \u00b1 ms); enumerate candidate slots and report a confidence, do not snap."
   ],
   "affectedConversions": [
    "local-to-slot"
   ],
   "page": "time-uncertainty"
  },
  {
   "id": "ntp-sync-state",
   "name": "NTP synchronization state",
   "category": "clock",
   "description": "A device whose clock is unsynchronized can be minutes off while still emitting well-formed timestamps. The format is valid; the value is not.",
   "detectionMethod": "Sync flag is false/unknown, or timestamps drift against a trusted reference.",
   "mitigation": [
    "Record synchronization state; widen uncertainty or quarantine data from unsynced clocks."
   ],
   "affectedConversions": [
    "local-to-slot"
   ],
   "page": "temporal-provenance"
  },
  {
   "id": "vm-snapshot-rollback",
   "name": "VM snapshot rollback",
   "category": "clock",
   "description": "Restoring a virtual machine from a snapshot moves its clock backwards, so a later event can carry an earlier timestamp than an earlier one.",
   "detectionMethod": "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."
   ],
   "affectedConversions": [
    "local-to-slot"
   ]
  },
  {
   "id": "container-migration",
   "name": "Container / live migration",
   "category": "clock",
   "description": "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.",
   "detectionMethod": "Host or clock-source identity changes across a stream.",
   "mitigation": [
    "Stamp the clock source/host with each batch; re-baseline uncertainty on migration."
   ],
   "affectedConversions": [
    "local-to-slot"
   ]
  },
  {
   "id": "offline-replay",
   "name": "Offline capture, delayed replay",
   "category": "clock",
   "description": "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.",
   "detectionMethod": "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)."
   ],
   "affectedConversions": [
    "local-to-slot",
    "slot-to-window"
   ]
  },
  {
   "id": "clock-correction-jumps",
   "name": "Clock-correction jumps",
   "category": "clock",
   "description": "After synchronization, NTP can step a clock several seconds backwards (rather than slewing), so consecutive events straddle a discontinuity and can invert in order.",
   "detectionMethod": "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."
   ],
   "affectedConversions": [
    "local-to-slot"
   ]
  },
  {
   "id": "multiple-clock-authorities",
   "name": "Multiple clock authorities disagree",
   "category": "clock",
   "description": "GPS, PTP, and NTP can disagree during outages or holdover. Which authority wins determines the timestamp, and the choice is often undocumented.",
   "detectionMethod": "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."
   ],
   "affectedConversions": [
    "local-to-slot"
   ],
   "page": "temporal-provenance"
  },
  {
   "id": "ai-inferred-timestamp",
   "name": "AI-inferred timestamp",
   "category": "ai",
   "description": "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.",
   "detectionMethod": "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."
   ],
   "affectedConversions": [
    "local-to-slot"
   ],
   "page": "temporal-provenance"
  },
  {
   "id": "synthetic-event-time",
   "name": "Synthetic event time",
   "category": "ai",
   "description": "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.",
   "detectionMethod": "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."
   ],
   "affectedConversions": [
    "local-to-slot"
   ]
  },
  {
   "id": "embedding-validity-time",
   "name": "Embedding validity time",
   "category": "ai",
   "description": "An embedding or feature computed months ago may no longer represent today's semantics. The datum has two times \u2014 when the event happened and when the representation was valid \u2014 and joining on the wrong one drifts the model.",
   "detectionMethod": "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."
   ],
   "affectedConversions": [
    "semantics"
   ]
  },
  {
   "id": "model-training-window",
   "name": "Model training window (temporal leakage)",
   "category": "ai",
   "description": "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.",
   "detectionMethod": "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."
   ],
   "affectedConversions": [
    "semantics"
   ]
  },
  {
   "id": "prediction-vs-observation-time",
   "name": "Prediction time vs observation time",
   "category": "ai",
   "description": "A forecast generated on Monday for Friday has two times \u2014 when it was made and what it is about. Storing only one makes the forecast unauditable and mixes horizons.",
   "detectionMethod": "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."
   ],
   "affectedConversions": [
    "semantics"
   ]
  },
  {
   "id": "partial-ordering-only",
   "name": "Partial ordering only",
   "category": "distributed",
   "description": "Two events on different nodes cannot always be globally ordered \u2014 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.",
   "detectionMethod": "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."
   ],
   "affectedConversions": [
    "semantics"
   ],
   "page": "causal-time-vs-physical-time"
  },
  {
   "id": "lamport-vector-clocks",
   "name": "Lamport / vector clocks (causal \u2260 UTC order)",
   "category": "distributed",
   "description": "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.",
   "detectionMethod": "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."
   ],
   "affectedConversions": [
    "semantics"
   ],
   "page": "causal-time-vs-physical-time"
  },
  {
   "id": "message-queue-delay",
   "name": "Message-queue delay and reordering",
   "category": "distributed",
   "description": "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.",
   "detectionMethod": "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."
   ],
   "affectedConversions": [
    "local-to-slot",
    "semantics"
   ]
  },
  {
   "id": "duplicate-event-replay",
   "name": "Duplicate event replay",
   "category": "distributed",
   "description": "At-least-once delivery replays the same event, with an identical event timestamp but a new ingestion time. Naive counting double-counts the slot.",
   "detectionMethod": "Repeated (idempotency key, event time) with differing ingestion times.",
   "mitigation": [
    "Deduplicate by idempotency key + event time before aggregating into a slot."
   ],
   "affectedConversions": [
    "local-to-slot",
    "semantics"
   ]
  },
  {
   "id": "event-versioning",
   "name": "Event versioning and corrections",
   "category": "distributed",
   "description": "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.",
   "detectionMethod": "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."
   ],
   "affectedConversions": [
    "semantics"
   ]
  },
  {
   "id": "false-precision",
   "name": "False precision",
   "category": "precision",
   "description": "A timestamp stored to nanoseconds from a clock accurate only to \u00b11 second implies precision the source never had. The extra digits are noise that can flip a near-boundary slot assignment.",
   "detectionMethod": "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."
   ],
   "affectedConversions": [
    "local-to-slot"
   ],
   "page": "time-uncertainty"
  },
  {
   "id": "mixed-precision-dataset",
   "name": "Mixed-precision dataset",
   "category": "precision",
   "description": "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.",
   "detectionMethod": "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."
   ],
   "affectedConversions": [
    "local-to-slot"
   ]
  },
  {
   "id": "averaged-timestamp",
   "name": "Averaged timestamp",
   "category": "precision",
   "description": "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.",
   "detectionMethod": "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."
   ],
   "affectedConversions": [
    "slot-to-window",
    "semantics"
   ]
  },
  {
   "id": "sampling-window",
   "name": "Sampling window vs instant",
   "category": "precision",
   "description": "A timestamp can denote a measurement INTERVAL rather than a moment \u2014 a one-minute average, a five-minute scrape. Treating the interval as an instant drops the fact that it may span multiple slots.",
   "detectionMethod": "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."
   ],
   "affectedConversions": [
    "slot-to-window"
   ]
  },
  {
   "id": "interval-center-vs-start",
   "name": "Interval center vs start vs end",
   "category": "precision",
   "description": "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.",
   "detectionMethod": "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."
   ],
   "affectedConversions": [
    "slot-to-window",
    "local-to-slot"
   ]
  }
 ],
 "conversionMethods": [
  {
   "id": "local-to-slot",
   "name": "Timestamp + zone \u2192 hour-of-week slot",
   "direction": "to_canonical",
   "sourceTypes": [
    "timestamp",
    "iana_zone"
   ],
   "destinationTypes": [
    "hour_of_week_slot"
   ],
   "exactness": "policy_dependent",
   "parameters": [
    {
     "name": "wall",
     "type": "local_datetime",
     "required": true,
     "description": "Local wall-clock reading (no offset)."
    },
    {
     "name": "zone",
     "type": "iana_zone",
     "required": true,
     "description": "IANA zone id, e.g. America/New_York."
    },
    {
     "name": "disambiguation",
     "type": "enum",
     "required": false,
     "default": "earliest",
     "description": "DST gap/fold policy: earliest | latest | reject."
    }
   ],
   "algorithm": "Resolve the local wall time to a UTC instant through the IANA tz database (handling the spring-forward gap and fall-back fold by the declared policy), then slot = ((weekdayMonday0 * 24) + hourUTC). slot 0 = Monday 00:00 UTC.",
   "assumptions": [
    "zone is a valid IANA id",
    "tz database version recorded in provenance"
   ],
   "outputs": [
    "slot",
    "epochMs",
    "offsetMinutes",
    "wasNonexistent",
    "wasAmbiguous"
   ],
   "qualityMetrics": [
    "lossless",
    "disambiguationApplied"
   ],
   "edgeCases": [
    "half-hour-offset-zones",
    "sub-hour-band-straddle",
    "spring-forward-gap",
    "fall-back-fold",
    "partial-hour-dst-shift",
    "dst-transition-time-varies",
    "non-dst-region-inside-dst-country",
    "tzdb-vintage-mismatch",
    "political-change-short-notice",
    "ambiguous-zone-abbreviations",
    "offset-is-not-a-zone",
    "windows-vs-iana-ids",
    "historical-offset-changes",
    "slot-origin-convention",
    "naive-datetime-no-zone",
    "local-read-as-utc",
    "epoch-and-unit-confusion",
    "clock-skew-and-sentinels",
    "timestamp-rounding-truncation",
    "leap-seconds",
    "date-line-weekday-divergence",
    "event-vs-ingestion-vs-report-time",
    "slot-boundary-dedup",
    "inferred-timezone-from-geo"
   ],
   "page": "timestamp-to-slot"
  },
  {
   "id": "slot-to-window",
   "name": "Slot + ISO week \u2192 UTC interval",
   "direction": "from_canonical",
   "sourceTypes": [
    "hour_of_week_slot",
    "iso_week"
   ],
   "destinationTypes": [
    "utc_interval"
   ],
   "exactness": "exact",
   "parameters": [
    {
     "name": "slot",
     "type": "integer",
     "required": true,
     "description": "Hour-of-week slot 0\u2013167."
    },
    {
     "name": "isoYear",
     "type": "integer",
     "required": true,
     "description": "ISO week-numbering year."
    },
    {
     "name": "isoWeek",
     "type": "integer",
     "required": true,
     "description": "ISO week number."
    }
   ],
   "algorithm": "start = Monday 00:00 UTC of the ISO week + slot hours; end = start + 1 hour. Deterministic UTC arithmetic; a slot is a one-UTC-hour interval.",
   "assumptions": [
    "(isoYear, isoWeek) carried together"
   ],
   "outputs": [
    "startUtc",
    "endUtc"
   ],
   "qualityMetrics": [],
   "edgeCases": [
    "no-silent-temporal-rollup",
    "leap-seconds"
   ],
   "page": "slot-to-utc-window"
  },
  {
   "id": "broadcast-day",
   "name": "Broadcast day (declared cutover) \u21c4 slot-set",
   "direction": "calendar",
   "sourceTypes": [
    "instant",
    "iana_zone"
   ],
   "destinationTypes": [
    "broadcast_day",
    "slot_set"
   ],
   "exactness": "policy_dependent",
   "parameters": [
    {
     "name": "zone",
     "type": "iana_zone",
     "required": true,
     "description": "IANA zone id."
    },
    {
     "name": "cutoverHour",
     "type": "integer",
     "required": false,
     "default": "6",
     "description": "Local hour the broadcast day starts (default 06:00)."
    }
   ],
   "algorithm": "A broadcast day for local date D runs cutover(D) \u2192 cutover(D+1); local hours before the cutover belong to the previous broadcast day. The interval is 23, 24, or 25 UTC hours across a DST transition; its slot-set is the UTC slots the interval touches.",
   "assumptions": [
    "cutover declared and echoed",
    "calendar-aware +1 day honours DST"
   ],
   "outputs": [
    "broadcastDate",
    "startUtc",
    "endUtc",
    "utcHours",
    "slots"
   ],
   "qualityMetrics": [
    "utcHours",
    "wrapsWeek"
   ],
   "edgeCases": [
    "half-hour-offset-zones",
    "forty-five-minute-offset-zones",
    "sub-hour-band-straddle",
    "spring-forward-gap",
    "fall-back-fold",
    "partial-hour-dst-shift",
    "dst-transition-time-varies",
    "tzdb-vintage-mismatch",
    "broadcast-calendar-month",
    "broadcast-day-cutover-varies"
   ],
   "page": "broadcast-day"
  },
  {
   "id": "daypart-slots",
   "name": "Daypart (local band) \u21c4 UTC slot-set",
   "direction": "annotation",
   "sourceTypes": [
    "daypart",
    "iana_zone",
    "iso_week"
   ],
   "destinationTypes": [
    "slot_set"
   ],
   "exactness": "weighted",
   "parameters": [
    {
     "name": "daypart",
     "type": "daypart",
     "required": true,
     "description": "Local hour band + weekdays."
    },
    {
     "name": "zone",
     "type": "iana_zone",
     "required": true,
     "description": "IANA zone id."
    },
    {
     "name": "isoWeek",
     "type": "iso_week",
     "required": true,
     "description": "Concrete week (DST depends on it)."
    }
   ],
   "algorithm": "For each (weekday, local hour) in the daypart, resolve the local time in the given zone and ISO week to a UTC instant, then to a slot. Sub-hour offsets and :30 band edges straddle UTC slots, so express membership as a weighted slot-set.",
   "assumptions": [
    "daypart is a declared local convention, not universal"
   ],
   "outputs": [
    "slots",
    "weights"
   ],
   "qualityMetrics": [
    "straddleFraction"
   ],
   "edgeCases": [
    "half-hour-offset-zones",
    "forty-five-minute-offset-zones",
    "sub-hour-band-straddle",
    "spring-forward-gap",
    "fall-back-fold",
    "southern-hemisphere-reversed-dst",
    "tzdb-vintage-mismatch",
    "extreme-offset-span",
    "utc-canonical-vs-local-experience",
    "inferred-timezone-from-geo"
   ],
   "page": "daypart-to-slots"
  },
  {
   "id": "iso-week",
   "name": "Instant \u2192 (ISO week \u00d7 slot) key",
   "direction": "calendar",
   "sourceTypes": [
    "instant"
   ],
   "destinationTypes": [
    "week_slot_key"
   ],
   "exactness": "exact",
   "parameters": [
    {
     "name": "instant",
     "type": "instant",
     "required": true,
     "description": "UTC instant (epoch ms)."
    }
   ],
   "algorithm": "Compute the ISO week-numbering year and week of the UTC instant and pair with the hour-of-week slot: 2026-W29-S045. The week-year can differ from the calendar year at the January boundary; always carry both.",
   "assumptions": [
    "week computed on the UTC instant (canonical)"
   ],
   "outputs": [
    "isoYear",
    "isoWeek",
    "slot",
    "key"
   ],
   "qualityMetrics": [],
   "edgeCases": [
    "week-numbering-systems",
    "week-year-boundary",
    "fifty-three-week-years",
    "broadcast-calendar-month",
    "slot-origin-convention",
    "date-line-weekday-divergence"
   ],
   "page": "iso-week"
  },
  {
   "id": "holidays",
   "name": "Local date \u2192 national holiday flag",
   "direction": "calendar",
   "sourceTypes": [
    "local_date",
    "country"
   ],
   "destinationTypes": [
    "holiday_flag"
   ],
   "exactness": "approximate",
   "parameters": [
    {
     "name": "date",
     "type": "local_date",
     "required": true,
     "description": "Local calendar date YYYY-MM-DD."
    },
    {
     "name": "country",
     "type": "country",
     "required": true,
     "description": "Country code (US | UK | CA in v0)."
    }
   ],
   "algorithm": "Evaluate public-calendar rules \u2014 fixed dates, nth/last weekday of month, and the Gregorian Easter computus \u2014 for the country and year, and test the date. A holiday is 24 local hours, i.e. a slot-set, not a single slot.",
   "assumptions": [
    "national rules only in v0; not authoritative for legal observance"
   ],
   "outputs": [
    "holiday",
    "name"
   ],
   "qualityMetrics": [],
   "edgeCases": [
    "substitute-day-holidays",
    "movable-and-regional-holidays"
   ],
   "page": "holidays"
  }
 ]
}