Skip to main content

logicaffeine_base/
temporal.rs

1//! Exact calendar primitives — the bedrock of the time tower.
2//!
3//! Everything here is **exact integer arithmetic** over a single absolute coordinate: the day
4//! count since the Unix epoch (1970-01-01). Two calendars are projected onto that coordinate —
5//! the **proleptic Gregorian** calendar (the civil default) and the **proleptic Julian** calendar
6//! (for historical dates and the Gregorian/Julian divergence) — so a date in either calendar is
7//! just a different *lens* on the same day number, and converting between them is lossless.
8//!
9//! The Gregorian conversions are Howard Hinnant's branch-light `days_from_civil`/`civil_from_days`
10//! (valid for any year, no lookup tables). The Julian conversions go through the Julian Day Number
11//! (`JDN`); the epoch 1970-01-01 is `JDN 2440588`. ISO-8601 week dates are derived from the day
12//! number directly. Weekday is `0 = Sunday … 6 = Saturday`; ISO weekday is `1 = Monday … 7 = Sunday`.
13
14/// The Julian Day Number of the Unix epoch (1970-01-01, Gregorian).
15pub const UNIX_EPOCH_JDN: i64 = 2_440_588;
16
17/// The IERS leap-second table: `(year, month, day, TAI−UTC seconds)` — the cumulative offset that
18/// takes effect at 00:00:00 UTC of the listed date. SmoothUTC (this module's default) ignores leap
19/// seconds; this table is the bridge to atomic time (TAI), which has no leaps. Through 2017-01-01
20/// (the most recent leap second; none scheduled since — the leap second is being phased out by 2035).
21const LEAP_SECONDS: &[(i64, u32, u32, i64)] = &[
22    (1972, 1, 1, 10), (1972, 7, 1, 11), (1973, 1, 1, 12), (1974, 1, 1, 13), (1975, 1, 1, 14),
23    (1976, 1, 1, 15), (1977, 1, 1, 16), (1978, 1, 1, 17), (1979, 1, 1, 18), (1980, 1, 1, 19),
24    (1981, 7, 1, 20), (1982, 7, 1, 21), (1983, 7, 1, 22), (1985, 7, 1, 23), (1988, 1, 1, 24),
25    (1990, 1, 1, 25), (1991, 1, 1, 26), (1992, 7, 1, 27), (1993, 7, 1, 28), (1994, 7, 1, 29),
26    (1996, 1, 1, 30), (1997, 7, 1, 31), (1999, 1, 1, 32), (2006, 1, 1, 33), (2009, 1, 1, 34),
27    (2012, 7, 1, 35), (2015, 7, 1, 36), (2017, 1, 1, 37),
28];
29
30/// The fixed `TT − TAI` offset: Terrestrial Time runs 32.184 s ahead of atomic time, exactly.
31pub const TT_MINUS_TAI_NANOS: i64 = 32_184_000_000;
32
33/// `TAI − UTC` (whole seconds) in effect at a SmoothUTC/Unix instant — the count of leap seconds
34/// (plus the 1972 base of 10) inserted on/before it. Constant between leap seconds; `10` before 1972.
35pub fn tai_minus_utc(unix_seconds: i64) -> i64 {
36    let mut offset = LEAP_SECONDS[0].3; // pre-1972 floor (10)
37    for &(y, m, d, value) in LEAP_SECONDS {
38        let effective = days_from_civil(y, m, d) * SECONDS_PER_DAY;
39        if effective <= unix_seconds {
40            offset = value;
41        } else {
42            break;
43        }
44    }
45    offset
46}
47
48/// Convert a SmoothUTC/Unix instant in **nanoseconds** to **TAI** nanoseconds (leap-second exact).
49pub fn tai_nanos_from_unix_nanos(unix_ns: i64) -> i64 {
50    let secs = unix_ns.div_euclid(NANOS_PER_SECOND);
51    unix_ns + tai_minus_utc(secs) * NANOS_PER_SECOND
52}
53
54/// Convert a SmoothUTC/Unix instant in **nanoseconds** to **Terrestrial Time (TT)** nanoseconds:
55/// `TT = TAI + 32.184 s` — the continuous astronomical scale ephemerides are tabulated against.
56pub fn tt_nanos_from_unix_nanos(unix_ns: i64) -> i64 {
57    tai_nanos_from_unix_nanos(unix_ns) + TT_MINUS_TAI_NANOS
58}
59
60/// The inverse: **TT** nanoseconds back to a SmoothUTC/Unix instant in nanoseconds.
61pub fn unix_nanos_from_tt_nanos(tt_ns: i64) -> i64 {
62    let tai_ns = tt_ns - TT_MINUS_TAI_NANOS;
63    // Recover the UTC second to know which leap offset applies, then subtract it at nanosecond scale.
64    let unix_secs = tai_to_unix_seconds(tai_ns.div_euclid(NANOS_PER_SECOND));
65    tai_ns - tai_minus_utc(unix_secs) * NANOS_PER_SECOND
66}
67
68/// Convert a SmoothUTC/Unix instant (seconds) to **TAI** (atomic time) seconds: `TAI = UTC + (TAI−UTC)`.
69pub fn unix_to_tai_seconds(unix_seconds: i64) -> i64 {
70    unix_seconds + tai_minus_utc(unix_seconds)
71}
72
73/// The inverse: **TAI** seconds back to a SmoothUTC/Unix instant. The offset is monotone in UTC, so
74/// a first guess (`tai − offset(tai)`) corrected against `offset(guess)` converges in one step.
75pub fn tai_to_unix_seconds(tai_seconds: i64) -> i64 {
76    let mut unix = tai_seconds - tai_minus_utc(tai_seconds);
77    for _ in 0..2 {
78        unix = tai_seconds - tai_minus_utc(unix);
79    }
80    unix
81}
82
83/// A **Hybrid Logical Clock** timestamp: a physical instant (nanoseconds) plus a logical counter
84/// that breaks ties and preserves causality when the physical clock stalls, jumps backward, or two
85/// events share a nanosecond. The total order is lexicographic `(physical_nanos, logical)` — so the
86/// derived `Ord` *is* the HLC order. This is the causally-consistent timestamp the `SyncClock` CRDT
87/// keys on: it stays monotonic and orders happens-before correctly even across machines whose wall
88/// clocks disagree (the bounded-skew / interplanetary case the campaign targets).
89#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
90pub struct Hlc {
91    pub physical_nanos: i64,
92    pub logical: u32,
93}
94
95impl Hlc {
96    /// The zero timestamp (before any event).
97    pub const ZERO: Hlc = Hlc { physical_nanos: 0, logical: 0 };
98
99    /// Stamp a **local event** (or a send) given the current physical clock `now_nanos`. Advances
100    /// the physical component to `max(self, now)`; the logical counter increments when physical
101    /// does not move (a stall or backward jump) and resets to 0 when it does.
102    pub fn tick(self, now_nanos: i64) -> Hlc {
103        let physical = self.physical_nanos.max(now_nanos);
104        let logical = if physical == self.physical_nanos { self.logical + 1 } else { 0 };
105        Hlc { physical_nanos: physical, logical }
106    }
107
108    /// Stamp the **receipt** of a message carrying timestamp `remote`, at local physical clock
109    /// `now_nanos`. Merges both clocks plus wall time, keeping the result strictly after both
110    /// inputs (the HLC receive rule) so causality (`send → receive`) is always preserved.
111    pub fn recv(self, remote: Hlc, now_nanos: i64) -> Hlc {
112        let physical = self.physical_nanos.max(remote.physical_nanos).max(now_nanos);
113        let logical = if physical == self.physical_nanos && physical == remote.physical_nanos {
114            self.logical.max(remote.logical) + 1
115        } else if physical == self.physical_nanos {
116            self.logical + 1
117        } else if physical == remote.physical_nanos {
118            remote.logical + 1
119        } else {
120            0
121        };
122        Hlc { physical_nanos: physical, logical }
123    }
124}
125
126/// A bound on a remote node's current time: it lies somewhere in `[lower_nanos, upper_nanos]`. The
127/// width is the irreducible uncertainty — across a light-delay you can never pin a remote clock
128/// tighter than the physics allows. The `intersect` of two valid bounds is a lattice meet (it never
129/// widens), which is what makes a [`SyncClock`] conflict-free.
130#[derive(Clone, Copy, Debug, PartialEq, Eq)]
131pub struct EstimateInterval {
132    pub lower_nanos: i64,
133    pub upper_nanos: i64,
134}
135
136impl EstimateInterval {
137    pub fn new(lower_nanos: i64, upper_nanos: i64) -> Self {
138        EstimateInterval { lower_nanos, upper_nanos }
139    }
140    /// The uncertainty width (`upper − lower`).
141    pub fn width(&self) -> i64 {
142        self.upper_nanos - self.lower_nanos
143    }
144    pub fn contains(&self, t: i64) -> bool {
145        self.lower_nanos <= t && t <= self.upper_nanos
146    }
147    /// The tightest interval consistent with both — the meet. Commutative, associative, idempotent.
148    pub fn intersect(&self, other: &EstimateInterval) -> EstimateInterval {
149        EstimateInterval {
150            lower_nanos: self.lower_nanos.max(other.lower_nanos),
151            upper_nanos: self.upper_nanos.min(other.upper_nanos),
152        }
153    }
154}
155
156/// The freshest remote time you can possibly *know*: an event happening at a node `light_delay_nanos`
157/// away cannot be observed before `now − light_delay`. This is the light-cone horizon — the reason a
158/// distributed clock's knowledge is principled-uncertain rather than sloppy.
159pub fn knowable_horizon(now_nanos: i64, light_delay_nanos: i64) -> i64 {
160    now_nanos - light_delay_nanos
161}
162
163/// A **light-cone-aware CRDT clock**: a Hybrid Logical Clock (causal order) plus per-node knowledge
164/// intervals (light-cone-bounded estimates of each peer's current time). Its `merge` is a join over
165/// a lattice — `max` on the HLC, interval intersection per node — so replicas separated by bounded
166/// (even interplanetary) delay converge conflict-free, regardless of message order. This realises
167/// "spacelike separation = CRDT concurrency": events with no shared light cone need no total order,
168/// only this commutative/associative/idempotent merge.
169#[derive(Clone, Debug, PartialEq, Eq)]
170pub struct SyncClock {
171    pub hlc: Hlc,
172    pub knowledge: std::collections::BTreeMap<u64, EstimateInterval>,
173}
174
175impl SyncClock {
176    pub fn new() -> Self {
177        SyncClock { hlc: Hlc::ZERO, knowledge: std::collections::BTreeMap::new() }
178    }
179
180    /// Stamp a local event (advance the HLC); knowledge is carried unchanged.
181    pub fn tick(&self, now_nanos: i64) -> SyncClock {
182        SyncClock { hlc: self.hlc.tick(now_nanos), knowledge: self.knowledge.clone() }
183    }
184
185    /// Record (and tighten) what this clock knows about node `node`'s current time. A fresh node is
186    /// inserted; a known node's interval is intersected, so observation can only *narrow* knowledge.
187    pub fn observe(&self, node: u64, interval: EstimateInterval) -> SyncClock {
188        let mut knowledge = self.knowledge.clone();
189        knowledge
190            .entry(node)
191            .and_modify(|iv| *iv = iv.intersect(&interval))
192            .or_insert(interval);
193        SyncClock { hlc: self.hlc, knowledge }
194    }
195
196    /// The CRDT join with another replica — `max` HLC (the lattice join on the derived total order),
197    /// per-node interval intersection on shared keys, union of distinct keys. Commutative,
198    /// associative, and idempotent, so gossip converges regardless of message order.
199    pub fn merge(&self, other: &SyncClock) -> SyncClock {
200        let mut knowledge = self.knowledge.clone();
201        for (&node, &interval) in &other.knowledge {
202            knowledge
203                .entry(node)
204                .and_modify(|iv| *iv = iv.intersect(&interval))
205                .or_insert(interval);
206        }
207        SyncClock { hlc: self.hlc.max(other.hlc), knowledge }
208    }
209}
210
211impl Default for SyncClock {
212    fn default() -> Self {
213        SyncClock::new()
214    }
215}
216
217/// Nanoseconds in one SI second.
218pub const NANOS_PER_SECOND: i64 = 1_000_000_000;
219/// Seconds in one civil day (SmoothUTC: no leap seconds, so every day is exactly 86 400 s).
220pub const SECONDS_PER_DAY: i64 = 86_400;
221/// Nanoseconds in one civil day.
222pub const NANOS_PER_DAY: i64 = SECONDS_PER_DAY * NANOS_PER_SECOND;
223
224/// A civil (wall-clock) date-time, calendar + time-of-day, with no zone — the human-readable
225/// decomposition of a **SmoothUTC** instant (nanoseconds since the Unix epoch, leap-second-free).
226#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
227pub struct CivilDateTime {
228    pub year: i64,
229    pub month: u32,
230    pub day: u32,
231    pub hour: u32,
232    pub minute: u32,
233    pub second: u32,
234    pub nanosecond: u32,
235}
236
237/// Decompose a SmoothUTC instant (nanoseconds since 1970-01-01T00:00:00) into its civil date-time.
238/// Floor division makes pre-epoch instants correct (e.g. `-1 ns` is `1969-12-31T23:59:59.999999999`).
239pub fn civil_from_unix_nanos(ns: i64) -> CivilDateTime {
240    let day = ns.div_euclid(NANOS_PER_DAY);
241    let rem = ns.rem_euclid(NANOS_PER_DAY); // [0, NANOS_PER_DAY)
242    let (year, month, d) = civil_from_days(day);
243    let secs = rem / NANOS_PER_SECOND;
244    CivilDateTime {
245        year,
246        month,
247        day: d,
248        hour: (secs / 3600) as u32,
249        minute: ((secs % 3600) / 60) as u32,
250        second: (secs % 60) as u32,
251        nanosecond: (rem % NANOS_PER_SECOND) as u32,
252    }
253}
254
255/// The inverse: the SmoothUTC instant (nanoseconds since the epoch) of a civil date-time.
256pub fn unix_nanos_from_civil(dt: CivilDateTime) -> i64 {
257    let days = days_from_civil(dt.year, dt.month, dt.day);
258    let secs = dt.hour as i64 * 3600 + dt.minute as i64 * 60 + dt.second as i64;
259    days * NANOS_PER_DAY + secs * NANOS_PER_SECOND + dt.nanosecond as i64
260}
261
262/// A UTC-offset transition: from `at_unix_seconds` (a UTC instant) onward, the zone's local time is
263/// `UTC + offset_seconds`. A timezone is a list of these, sorted ascending — the shape every IANA
264/// zone reduces to. DST is just two alternating offsets with transitions twice a year.
265#[derive(Clone, Copy, Debug, PartialEq, Eq)]
266pub struct ZoneTransition {
267    pub at_unix_seconds: i64,
268    pub offset_seconds: i32,
269}
270
271/// How to resolve a local civil time that maps ambiguously to UTC: in a DST **fold** (the hour
272/// repeated when clocks fall back, so a local time occurs twice) or a **gap** (the hour skipped
273/// when clocks spring forward, so a local time never occurs). `Earlier` picks the earlier instant,
274/// `Later` the later one.
275#[derive(Clone, Copy, Debug, PartialEq, Eq)]
276pub enum Fold {
277    Earlier,
278    Later,
279}
280
281/// The UTC offset (seconds) a zone has at `instant_ns`. `transitions` must be sorted ascending by
282/// `at_unix_seconds`; `base_offset` applies before the first transition.
283pub fn offset_at(transitions: &[ZoneTransition], base_offset: i32, instant_ns: i64) -> i32 {
284    let secs = instant_ns.div_euclid(NANOS_PER_SECOND);
285    let mut off = base_offset;
286    for t in transitions {
287        if t.at_unix_seconds <= secs {
288            off = t.offset_seconds;
289        } else {
290            break;
291        }
292    }
293    off
294}
295
296/// The local civil date-time of a UTC instant in a zone (apply the offset in effect).
297pub fn to_local(transitions: &[ZoneTransition], base_offset: i32, instant_ns: i64) -> CivilDateTime {
298    let off = offset_at(transitions, base_offset, instant_ns);
299    civil_from_unix_nanos(instant_ns + off as i64 * NANOS_PER_SECOND)
300}
301
302/// The UTC instant of a local civil date-time in a zone. A local time normally maps to exactly one
303/// instant; in a DST fold it maps to two (resolved by `fold`); in a DST gap it maps to none (the
304/// boundary instants are returned, `fold` choosing which). This is the offset-dependent inverse of
305/// [`to_local`], solved by testing each candidate offset for self-consistency.
306pub fn from_local(
307    transitions: &[ZoneTransition],
308    base_offset: i32,
309    civil: CivilDateTime,
310    fold: Fold,
311) -> i64 {
312    let local_ns = unix_nanos_from_civil(civil);
313    // The distinct offsets the zone ever uses are the only candidates.
314    let mut offsets: Vec<i32> = vec![base_offset];
315    for t in transitions {
316        if !offsets.contains(&t.offset_seconds) {
317            offsets.push(t.offset_seconds);
318        }
319    }
320    // A candidate is self-consistent when the offset it assumes is actually the one in effect at
321    // the UTC instant it implies: `offset_at(local − offset) == offset`.
322    let mut valid: Vec<i64> = Vec::new();
323    for o in &offsets {
324        let utc = local_ns - *o as i64 * NANOS_PER_SECOND;
325        if offset_at(transitions, base_offset, utc) == *o {
326            valid.push(utc);
327        }
328    }
329    valid.sort_unstable();
330    valid.dedup();
331    if valid.len() >= 2 {
332        // Fold: the local time occurs twice.
333        return match fold {
334            Fold::Earlier => valid[0],
335            Fold::Later => *valid.last().unwrap(),
336        };
337    }
338    if valid.len() == 1 {
339        return valid[0];
340    }
341    // Gap: the local time never occurs — return a boundary instant per `fold`.
342    let mut cands: Vec<i64> = offsets.iter().map(|o| local_ns - *o as i64 * NANOS_PER_SECOND).collect();
343    cands.sort_unstable();
344    match fold {
345        Fold::Earlier => cands[0],
346        Fold::Later => *cands.last().unwrap(),
347    }
348}
349
350/// A POSIX-style DST transition rule: the `week`-th `weekday` of `month`, at `time_seconds` local.
351/// `week` is `1..=5` (5 = the last occurrence); `weekday` is `0 = Sunday … 6 = Saturday`. This is
352/// the `Mm.w.d` form a TZ string uses (e.g. `M3.2.0` = the 2nd Sunday of March), and it generates
353/// one transition per year — enough to express any zone with a regular DST schedule.
354#[derive(Clone, Copy, Debug, PartialEq, Eq)]
355pub struct DstRule {
356    pub month: u32,
357    pub week: u32,
358    pub weekday: u32,
359    pub time_seconds: i64,
360}
361
362/// The day-of-month of the `week`-th `weekday` of `(year, month)` — e.g. the 2nd Sunday of March,
363/// or (with `week = 5`) the last Sunday of October. `week` is `1..=5`, `weekday` `0 = Sun … 6 = Sat`.
364pub fn nth_weekday_of_month(year: i64, month: u32, week: u32, weekday: u32) -> u32 {
365    let first = days_from_civil(year, month, 1);
366    let first_weekday = weekday_from_days(first);
367    let to_first = (weekday + 7 - first_weekday) % 7; // days from the 1st to the first `weekday`
368    let mut day = 1 + to_first + (week - 1) * 7;
369    let last = last_day_of_month(year, month);
370    while day > last {
371        day -= 7; // `week = 5` past the month's end → the last occurrence
372    }
373    day
374}
375
376/// The two UTC transitions for `year` of a zone with a regular DST schedule: spring-forward
377/// (`std_offset → dst_offset`) at `start`, fall-back (`dst_offset → std_offset`) at `end`. Each
378/// rule's `time_seconds` is wall-clock local in the offset in effect just before the transition.
379pub fn dst_transitions_for_year(
380    std_offset: i32,
381    dst_offset: i32,
382    start: DstRule,
383    end: DstRule,
384    year: i64,
385) -> [ZoneTransition; 2] {
386    // A rule's local wall-clock time is in the offset in effect just before the jump, so the UTC
387    // instant is `local − pre_offset`. The new offset takes effect from there.
388    let transition = |rule: DstRule, pre_offset: i32, new_offset: i32| {
389        let day = nth_weekday_of_month(year, rule.month, rule.week, rule.weekday);
390        let local = days_from_civil(year, rule.month, day) * SECONDS_PER_DAY + rule.time_seconds;
391        ZoneTransition { at_unix_seconds: local - pre_offset as i64, offset_seconds: new_offset }
392    };
393    [
394        transition(start, std_offset, dst_offset), // spring forward: STD → DST
395        transition(end, dst_offset, std_offset),   // fall back: DST → STD
396    ]
397}
398
399/// A named time zone: its standard UTC offset, and an optional DST schedule `(dst_offset, spring
400/// rule, fall rule)`. A zone with `dst = None` is a fixed offset (UTC, IST, JST). DST transitions
401/// are *generated* per year from the rules, so a `ZoneSpec` is tiny — no transition table.
402#[derive(Clone, Copy, Debug, PartialEq, Eq)]
403pub struct ZoneSpec {
404    pub name: &'static str,
405    pub std_offset: i32,
406    pub dst: Option<(i32, DstRule, DstRule)>,
407}
408
409impl ZoneSpec {
410    /// The zone's UTC transitions for every year in `start_year..=end_year`, sorted ascending
411    /// (empty for a fixed-offset zone).
412    pub fn transitions(&self, start_year: i64, end_year: i64) -> Vec<ZoneTransition> {
413        let mut v = Vec::new();
414        if let Some((dst_offset, start, end)) = self.dst {
415            for y in start_year..=end_year {
416                let pair = dst_transitions_for_year(self.std_offset, dst_offset, start, end, y);
417                v.extend_from_slice(&pair);
418            }
419            v.sort_by_key(|t| t.at_unix_seconds);
420        }
421        v
422    }
423
424    /// The local civil date-time of a UTC instant in this zone (DST resolved from the rules).
425    pub fn to_local(&self, instant_ns: i64) -> CivilDateTime {
426        if self.dst.is_none() {
427            return civil_from_unix_nanos(instant_ns + self.std_offset as i64 * NANOS_PER_SECOND);
428        }
429        // Generate transitions for the instant's year ±1 so it is always past the first one (this
430        // makes `std_offset` as the pre-first base harmless, including the southern hemisphere).
431        let (year, _, _) = civil_from_days(instant_ns.div_euclid(NANOS_PER_DAY));
432        let tz = self.transitions(year - 1, year + 1);
433        to_local(&tz, self.std_offset, instant_ns)
434    }
435}
436
437/// The **local-as-UTC instant** of a UTC instant in a named zone: the nanoseconds that, read back
438/// with the plain UTC calendar functions, yield the zone's *local* wall-clock components. This is
439/// the lowering target for zoned component reads (`the hour of m in "Asia/Tokyo"`): a local clock
440/// face encoded as an instant, so every UTC extractor composes onto it unchanged. `None` if the
441/// zone is unknown. (Not a real instant — it is the wall clock; do not re-format it with a `Z`.)
442pub fn local_instant_nanos(instant_ns: i64, zone_name: &str) -> Option<i64> {
443    let zone = zone_by_name(zone_name)?;
444    Some(unix_nanos_from_civil(zone.to_local(instant_ns)))
445}
446
447/// Format a UTC instant as the **local wall-clock time in a named zone**, with its offset —
448/// `2024-07-01T08:00:00-04:00` (the timezone-aware "relative read" of an instant). `None` if the
449/// zone is unknown. The space-aware/zoned generalisation of [`format_rfc3339`].
450pub fn format_zoned(instant_ns: i64, zone_name: &str) -> Option<String> {
451    let zone = zone_by_name(zone_name)?;
452    let local = zone.to_local(instant_ns);
453    // local-as-UTC minus the true instant is exactly the zone's offset at that moment.
454    let offset_secs = (unix_nanos_from_civil(local) - instant_ns) / NANOS_PER_SECOND;
455    let sign = if offset_secs < 0 { '-' } else { '+' };
456    let abs = offset_secs.abs();
457    Some(format!(
458        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}{}{:02}:{:02}",
459        local.year, local.month, local.day, local.hour, local.minute, local.second,
460        sign, abs / 3600, (abs % 3600) / 60,
461    ))
462}
463
464/// Look up a [`ZoneSpec`] by IANA name (e.g. `"America/New_York"`, `"UTC"`, `"Australia/Sydney"`).
465/// `None` for an unknown zone. A growable registry seeded with common zones (POSIX-rule based);
466/// the full IANA set ingests later.
467pub fn zone_by_name(name: &str) -> Option<ZoneSpec> {
468    // North-American DST: 2nd Sunday of March → 1st Sunday of November, both at 02:00 local.
469    let us = (
470        DstRule { month: 3, week: 2, weekday: 0, time_seconds: 2 * 3600 },
471        DstRule { month: 11, week: 1, weekday: 0, time_seconds: 2 * 3600 },
472    );
473    // EU DST changes at 01:00 UTC: the local wall-clock time is `01:00 + std` (pre-transition).
474    let eu = |std_h: i64| {
475        (
476            DstRule { month: 3, week: 5, weekday: 0, time_seconds: (std_h + 1) * 3600 },
477            DstRule { month: 10, week: 5, weekday: 0, time_seconds: (std_h + 2) * 3600 },
478        )
479    };
480    let spec = match name {
481        "UTC" | "Etc/UTC" | "Zulu" => ZoneSpec { name: "UTC", std_offset: 0, dst: None },
482        "America/New_York" => ZoneSpec { name: "America/New_York", std_offset: -5 * 3600, dst: Some((-4 * 3600, us.0, us.1)) },
483        "America/Chicago" => ZoneSpec { name: "America/Chicago", std_offset: -6 * 3600, dst: Some((-5 * 3600, us.0, us.1)) },
484        "America/Denver" => ZoneSpec { name: "America/Denver", std_offset: -7 * 3600, dst: Some((-6 * 3600, us.0, us.1)) },
485        "America/Los_Angeles" => ZoneSpec { name: "America/Los_Angeles", std_offset: -8 * 3600, dst: Some((-7 * 3600, us.0, us.1)) },
486        "America/Phoenix" => ZoneSpec { name: "America/Phoenix", std_offset: -7 * 3600, dst: None },
487        "Europe/London" | "Europe/Dublin" | "Europe/Lisbon" => {
488            let (s, e) = eu(0);
489            ZoneSpec { name: "Europe/London", std_offset: 0, dst: Some((3600, s, e)) }
490        }
491        "Europe/Paris" | "Europe/Berlin" | "Europe/Madrid" | "Europe/Rome" | "Europe/Amsterdam" => {
492            let (s, e) = eu(1);
493            ZoneSpec { name: "Europe/Paris", std_offset: 3600, dst: Some((2 * 3600, s, e)) }
494        }
495        "Asia/Kolkata" | "Asia/Calcutta" => ZoneSpec { name: "Asia/Kolkata", std_offset: 5 * 3600 + 1800, dst: None },
496        "Asia/Tokyo" => ZoneSpec { name: "Asia/Tokyo", std_offset: 9 * 3600, dst: None },
497        "Asia/Shanghai" => ZoneSpec { name: "Asia/Shanghai", std_offset: 8 * 3600, dst: None },
498        "Australia/Sydney" => ZoneSpec {
499            name: "Australia/Sydney",
500            std_offset: 10 * 3600,
501            // Southern hemisphere: spring-forward in October, fall-back in April.
502            dst: Some((
503                11 * 3600,
504                DstRule { month: 10, week: 1, weekday: 0, time_seconds: 2 * 3600 },
505                DstRule { month: 4, week: 1, weekday: 0, time_seconds: 3 * 3600 },
506            )),
507        },
508        _ => return None,
509    };
510    Some(spec)
511}
512
513/// Add a **calendar span** (`months` then `days`) to a civil date-time — the *civil* (wall-clock)
514/// operation, distinct from adding a physical `Duration`. Months are applied first with
515/// **end-of-month clamping** (`Jan 31 + 1 month = Feb 28/29`), then whole `days` roll over exactly
516/// through the day number. The time-of-day is preserved. (`Span` arithmetic on a *zoned* value is
517/// where it diverges from a physical duration across a DST boundary; in UTC the two agree.)
518pub fn add_span(dt: CivilDateTime, months: i64, days: i64) -> CivilDateTime {
519    let total = (dt.year * 12 + (dt.month as i64 - 1)) + months;
520    let new_year = total.div_euclid(12);
521    let new_month = (total.rem_euclid(12) + 1) as u32;
522    let clamped_day = dt.day.min(last_day_of_month(new_year, new_month));
523    let z = days_from_civil(new_year, new_month, clamped_day) + days;
524    let (year, month, day) = civil_from_days(z);
525    CivilDateTime { year, month, day, ..dt }
526}
527
528/// The number of **complete calendar months** from instant `a` to instant `b` (signed; negative when
529/// `b` precedes `a`). A month is complete only when `b` reaches at least `a + k months` under the same
530/// end-of-month-clamping, time-of-day-preserving rule as [`add_span`] — so `Jan 15 → Mar 14` is 1
531/// month (not 2), `Jan 31 → Feb 28` (non-leap) is a full month, and one hour short of a month is 0.
532pub fn months_between(a_nanos: i64, b_nanos: i64) -> i64 {
533    if b_nanos < a_nanos {
534        return -months_between(b_nanos, a_nanos);
535    }
536    let a = civil_from_unix_nanos(a_nanos);
537    let b = civil_from_unix_nanos(b_nanos);
538    let mut months = (b.year - a.year) * 12 + (b.month as i64 - a.month as i64);
539    // Matching only year+month can overshoot by the day/time-of-day; if `a + months` lands after `b`
540    // the final month has not completed, so step back one. (At most one correction is ever needed.)
541    if unix_nanos_from_civil(add_span(a, months, 0)) > b_nanos {
542        months -= 1;
543    }
544    months
545}
546
547/// The number of **complete calendar years** from `a` to `b` (signed) — complete 12-month periods,
548/// so `2020-06-01 → 2024-03-01` is 3 (the fourth year, ending 2024-06-01, has not arrived).
549pub fn years_between(a_nanos: i64, b_nanos: i64) -> i64 {
550    months_between(a_nanos, b_nanos) / 12
551}
552
553/// True if a day number falls on a weekend (Saturday or Sunday).
554pub fn is_weekend(z: i64) -> bool {
555    matches!(weekday_from_days(z), 0 | 6) // 0 = Sunday, 6 = Saturday
556}
557
558/// The count of **business days** (Mon–Fri) in the half-open interval between two day numbers.
559/// Signed by direction (`b < a` yields a negative count); `[a, b)` so adjacent days differ by 1.
560pub fn business_days_between(a: i64, b: i64) -> i64 {
561    if a == b {
562        return 0;
563    }
564    let (lo, hi, sign) = if a < b { (a, b, 1) } else { (b, a, -1) };
565    let span = hi - lo;
566    let full_weeks = span / 7;
567    let mut count = full_weeks * 5;
568    let mut d = lo + full_weeks * 7;
569    while d < hi {
570        if !is_weekend(d) {
571            count += 1;
572        }
573        d += 1;
574    }
575    sign * count
576}
577
578/// Advance a day number by `n` **business days** (skipping weekends): `n > 0` moves forward,
579/// `n < 0` backward. Landing rule is "next business day in the direction of travel", so
580/// `add_business_days(friday, 1)` is the following Monday and `add_business_days(monday, -1)` is
581/// the preceding Friday.
582pub fn add_business_days(z: i64, n: i64) -> i64 {
583    let step = if n >= 0 { 1 } else { -1 };
584    let mut remaining = n.abs();
585    let mut d = z;
586    while remaining > 0 {
587        d += step;
588        if !is_weekend(d) {
589            remaining -= 1;
590        }
591    }
592    d
593}
594
595/// Format a **physical duration** (nanoseconds) as a compact human string — `1h30m`, `500ms`,
596/// `1.5s`, `2d`, `-45m`, `0s`. Largest unit first; zero components omitted; sub-second fractions
597/// trimmed. The inverse of [`parse_duration`]. (This is a *physical* elapsed time, distinct from a
598/// calendar `Span`: it has no months — those aren't a fixed number of nanoseconds.)
599pub fn format_duration(nanos: i64) -> String {
600    if nanos == 0 {
601        return "0s".to_string();
602    }
603    const D: u128 = 86_400_000_000_000;
604    const H: u128 = 3_600_000_000_000;
605    const M: u128 = 60_000_000_000;
606    const S: u128 = 1_000_000_000;
607    const MS: u128 = 1_000_000;
608    const US: u128 = 1_000;
609    let mut out = String::new();
610    if nanos < 0 {
611        out.push('-');
612    }
613    let mut abs = (nanos as i128).unsigned_abs();
614    // A fractional sub-unit, trimmed of trailing zeros (`frac` has `width` digits).
615    let frac_str = |frac: u128, width: usize| -> String {
616        let mut f = format!("{:0width$}", frac, width = width);
617        while f.ends_with('0') {
618            f.pop();
619        }
620        f
621    };
622    if abs >= S {
623        let d = abs / D;
624        abs %= D;
625        let h = abs / H;
626        abs %= H;
627        let m = abs / M;
628        abs %= M;
629        if d > 0 {
630            out += &format!("{d}d");
631        }
632        if h > 0 {
633            out += &format!("{h}h");
634        }
635        if m > 0 {
636            out += &format!("{m}m");
637        }
638        let (secs, frac) = (abs / S, abs % S);
639        if frac == 0 {
640            if secs > 0 {
641                out += &format!("{secs}s");
642            }
643        } else {
644            out += &format!("{secs}.{}s", frac_str(frac, 9));
645        }
646    } else if abs >= MS {
647        let (w, frac) = (abs / MS, abs % MS);
648        out += &(if frac == 0 { format!("{w}ms") } else { format!("{w}.{}ms", frac_str(frac, 6)) });
649    } else if abs >= US {
650        let (w, frac) = (abs / US, abs % US);
651        out += &(if frac == 0 { format!("{w}us") } else { format!("{w}.{}us", frac_str(frac, 3)) });
652    } else {
653        out += &format!("{abs}ns");
654    }
655    out
656}
657
658/// Parse a **physical duration** string into nanoseconds — `1h30m`, `90m`, `1.5s`, `500ms`, `2d`,
659/// `-1h`, `1d2h3m4s`. Units: `d h m s ms us`/`µs` `ns`. `None` on malformed input or i64 overflow.
660pub fn parse_duration(s: &str) -> Option<i64> {
661    let b = s.as_bytes();
662    let mut i = 0;
663    let neg = match b.first() {
664        Some(&b'-') => { i += 1; true }
665        Some(&b'+') => { i += 1; false }
666        _ => false,
667    };
668    if i >= b.len() {
669        return None;
670    }
671    let mut total: i128 = 0;
672    let mut saw_any = false;
673    while i < b.len() {
674        let int_start = i;
675        while i < b.len() && b[i].is_ascii_digit() {
676            i += 1;
677        }
678        let int_str = &s[int_start..i];
679        let mut frac_str = "";
680        if i < b.len() && b[i] == b'.' {
681            i += 1;
682            let frac_start = i;
683            while i < b.len() && b[i].is_ascii_digit() {
684                i += 1;
685            }
686            frac_str = &s[frac_start..i];
687        }
688        if int_str.is_empty() && frac_str.is_empty() {
689            return None; // a unit with no number
690        }
691        let rest = &s[i..];
692        let (unit_nanos, ulen): (i128, usize) = if rest.starts_with("ms") {
693            (1_000_000, 2)
694        } else if rest.starts_with("ns") {
695            (1, 2)
696        } else if rest.starts_with("us") {
697            (1_000, 2)
698        } else if rest.starts_with("µs") {
699            (1_000, "µs".len())
700        } else if rest.starts_with('s') {
701            (1_000_000_000, 1)
702        } else if rest.starts_with('m') {
703            (60_000_000_000, 1)
704        } else if rest.starts_with('h') {
705            (3_600_000_000_000, 1)
706        } else if rest.starts_with('d') {
707            (86_400_000_000_000, 1)
708        } else {
709            return None; // unknown / missing unit
710        };
711        i += ulen;
712        let int_val: i128 = if int_str.is_empty() { 0 } else { int_str.parse().ok()? };
713        total = total.checked_add(int_val.checked_mul(unit_nanos)?)?;
714        if !frac_str.is_empty() {
715            let frac_val: i128 = frac_str.parse().ok()?;
716            let mut denom: i128 = 1;
717            for _ in 0..frac_str.len() {
718                denom *= 10;
719            }
720            total = total.checked_add(frac_val * unit_nanos / denom)?;
721        }
722        saw_any = true;
723    }
724    if !saw_any {
725        return None;
726    }
727    i64::try_from(if neg { -total } else { total }).ok()
728}
729
730/// Format a SmoothUTC instant as an **RFC 3339 / ISO 8601** UTC timestamp (`Z` zone), e.g.
731/// `1970-01-01T00:00:00Z` or `2024-03-10T07:30:00.123456789Z`. Sub-second precision appears only
732/// when nonzero, with trailing zeros trimmed.
733pub fn format_rfc3339(instant_ns: i64) -> String {
734    let c = civil_from_unix_nanos(instant_ns);
735    let mut s = format!(
736        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}",
737        c.year, c.month, c.day, c.hour, c.minute, c.second
738    );
739    if c.nanosecond != 0 {
740        let mut frac = format!("{:09}", c.nanosecond);
741        while frac.ends_with('0') {
742            frac.pop();
743        }
744        s.push('.');
745        s.push_str(&frac);
746    }
747    s.push('Z');
748    s
749}
750
751/// Format a **time-of-day** (nanoseconds from midnight) as `HH:MM:SS`, with a trailing sub-second
752/// fraction only when nonzero (trailing zeros trimmed) — e.g. `07:30:45`, `16:00:00`,
753/// `07:30:45.5`. The clock face is lossless to the nanosecond; the input is normalised modulo a day
754/// so a negative or overflowing value still maps onto `[00:00:00, 24:00:00)`.
755pub fn format_time_of_day(nanos_from_midnight: i64) -> String {
756    let rem = nanos_from_midnight.rem_euclid(NANOS_PER_DAY);
757    let secs = rem / NANOS_PER_SECOND;
758    let mut s = format!("{:02}:{:02}:{:02}", secs / 3600, (secs % 3600) / 60, secs % 60);
759    let nanos = (rem % NANOS_PER_SECOND) as u32;
760    if nanos != 0 {
761        let mut frac = format!("{:09}", nanos);
762        while frac.ends_with('0') {
763            frac.pop();
764        }
765        s.push('.');
766        s.push_str(&frac);
767    }
768    s
769}
770
771/// Parse an **RFC 3339 / ISO 8601** timestamp into a SmoothUTC instant (nanoseconds since epoch).
772/// Accepts a `Z` zone or a numeric `±HH:MM` offset (normalized to UTC); `None` on malformed input.
773pub fn parse_rfc3339(s: &str) -> Option<i64> {
774    let b = s.as_bytes();
775    // The fixed `YYYY-MM-DDTHH:MM:SS` prefix is 19 bytes; a zone always follows.
776    if b.len() < 20 {
777        return None;
778    }
779    let digits = |start: usize, len: usize| -> Option<i64> {
780        let mut v: i64 = 0;
781        for i in start..start + len {
782            let c = *b.get(i)?;
783            if !c.is_ascii_digit() {
784                return None;
785            }
786            v = v * 10 + (c - b'0') as i64;
787        }
788        Some(v)
789    };
790    if b[4] != b'-' || b[7] != b'-' || b[10] != b'T' || b[13] != b':' || b[16] != b':' {
791        return None;
792    }
793    let year = digits(0, 4)?;
794    let month = digits(5, 2)? as u32;
795    let day = digits(8, 2)? as u32;
796    let hour = digits(11, 2)? as u32;
797    let minute = digits(14, 2)? as u32;
798    let second = digits(17, 2)? as u32;
799    if !(1..=12).contains(&month) || !(1..=last_day_of_month(year, month)).contains(&day) {
800        return None;
801    }
802    if hour > 23 || minute > 59 || second > 59 {
803        return None;
804    }
805    let mut pos = 19;
806    // Optional fractional seconds, scaled to nanoseconds (9 digits; extra digits truncated).
807    let mut nanosecond: u32 = 0;
808    if b.get(pos) == Some(&b'.') {
809        pos += 1;
810        let (mut frac, mut count) = (0u64, 0u32);
811        while let Some(&c) = b.get(pos) {
812            if !c.is_ascii_digit() {
813                break;
814            }
815            if count < 9 {
816                frac = frac * 10 + (c - b'0') as u64;
817                count += 1;
818            }
819            pos += 1;
820        }
821        if count == 0 {
822            return None; // a '.' with no digits
823        }
824        for _ in count..9 {
825            frac *= 10;
826        }
827        nanosecond = frac as u32;
828    }
829    // Zone: `Z` (UTC) or a numeric `±HH:MM` offset.
830    let offset_seconds: i64 = match b.get(pos) {
831        Some(&b'Z') => {
832            pos += 1;
833            0
834        }
835        Some(&c) if c == b'+' || c == b'-' => {
836            let sign = if c == b'+' { 1 } else { -1 };
837            let oh = digits(pos + 1, 2)?;
838            if b.get(pos + 3) != Some(&b':') {
839                return None;
840            }
841            let om = digits(pos + 4, 2)?;
842            if oh > 23 || om > 59 {
843                return None;
844            }
845            pos += 6;
846            sign * (oh * 3600 + om * 60)
847        }
848        _ => return None,
849    };
850    if pos != b.len() {
851        return None; // trailing garbage
852    }
853    let civil = CivilDateTime { year, month, day, hour, minute, second, nanosecond };
854    Some(unix_nanos_from_civil(civil) - offset_seconds * NANOS_PER_SECOND)
855}
856
857/// True if `year` is a leap year in the proleptic **Gregorian** calendar (every 4th year, except
858/// centuries, except every 400th).
859pub fn is_gregorian_leap(year: i64) -> bool {
860    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
861}
862
863/// True if `year` is a leap year in the proleptic **Julian** calendar (every 4th year, no
864/// century exception).
865pub fn is_julian_leap(year: i64) -> bool {
866    year.rem_euclid(4) == 0
867}
868
869/// Days since 1970-01-01 for a **Gregorian** civil date. Howard Hinnant's algorithm — exact for
870/// any year, `month` in `1..=12`, `day` in `1..=last_day_of_month`.
871pub fn days_from_civil(year: i64, month: u32, day: u32) -> i64 {
872    let y = if month <= 2 { year - 1 } else { year };
873    let era = (if y >= 0 { y } else { y - 399 }) / 400;
874    let yoe = (y - era * 400) as i64; // [0, 399]
875    let m = month as i64;
876    let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + day as i64 - 1; // [0, 365]
877    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096]
878    era * 146097 + doe - 719468
879}
880
881/// The inverse of [`days_from_civil`]: the **Gregorian** `(year, month, day)` of a day number.
882pub fn civil_from_days(z: i64) -> (i64, u32, u32) {
883    let z = z + 719468;
884    let era = (if z >= 0 { z } else { z - 146096 }) / 146097;
885    let doe = z - era * 146097; // [0, 146096]
886    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // [0, 399]
887    let y = yoe + era * 400;
888    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
889    let mp = (5 * doy + 2) / 153; // [0, 11]
890    let day = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
891    let month = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32; // [1, 12]
892    let year = if month <= 2 { y + 1 } else { y };
893    (year, month, day)
894}
895
896/// The last day (28–31) of a **Gregorian** month.
897pub fn last_day_of_month(year: i64, month: u32) -> u32 {
898    match month {
899        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
900        4 | 6 | 9 | 11 => 30,
901        2 => if is_gregorian_leap(year) { 29 } else { 28 },
902        _ => 0,
903    }
904}
905
906/// The weekday of a day number: `0 = Sunday … 6 = Saturday`. (1970-01-01 was a Thursday = 4.)
907pub fn weekday_from_days(z: i64) -> u32 {
908    (z + 4).rem_euclid(7) as u32
909}
910
911/// The ISO-8601 weekday of a day number: `1 = Monday … 7 = Sunday`.
912pub fn iso_weekday_from_days(z: i64) -> u32 {
913    (z + 3).rem_euclid(7) as u32 + 1
914}
915
916/// The 1-based day of the **Gregorian** year (`1..=366`).
917pub fn day_of_year(year: i64, month: u32, day: u32) -> u32 {
918    (days_from_civil(year, month, day) - days_from_civil(year, 1, 1) + 1) as u32
919}
920
921/// The **ISO-8601 week date** of a day number: `(iso_year, week, iso_weekday)`, with
922/// `week` in `1..=53` and `iso_weekday` in `1..=7` (Monday … Sunday). The ISO year can differ
923/// from the civil year near January 1 / December 31 (e.g. 2021-01-01 is ISO 2020-W53-5).
924pub fn iso_week_from_days(z: i64) -> (i64, u32, u32) {
925    let iso_dow = iso_weekday_from_days(z);
926    // The Thursday of this ISO week (days 1..7 = Mon..Sun, so Thursday = day 4) names the ISO year.
927    let thursday = z + (4 - iso_dow as i64);
928    let (iso_year, _, _) = civil_from_days(thursday);
929    // ISO week 1 is the week containing January 4 (equivalently, the year's first Thursday); its
930    // Monday anchors week numbering. (Jan 4's *week's* Thursday is always in `iso_year`, unlike
931    // Jan 1's, which can fall in the previous year.)
932    let jan4 = days_from_civil(iso_year, 1, 4);
933    let week1_monday = jan4 - (iso_weekday_from_days(jan4) as i64 - 1);
934    let week = ((z - week1_monday) / 7 + 1) as u32;
935    (iso_year, week, iso_dow)
936}
937
938/// The inverse of [`iso_week_from_days`]: the day number of an ISO-8601 week date
939/// `(iso_year, week, iso_weekday)` (week `1..=53`, weekday `1..=7` Monday … Sunday).
940pub fn days_from_iso_week(iso_year: i64, week: u32, iso_weekday: u32) -> i64 {
941    let jan4 = days_from_civil(iso_year, 1, 4);
942    let week1_monday = jan4 - (iso_weekday_from_days(jan4) as i64 - 1);
943    week1_monday + (week as i64 - 1) * 7 + (iso_weekday as i64 - 1)
944}
945
946/// The Julian Day Number of a **Julian-calendar** date (proleptic, `month` in `1..=12`).
947pub fn jdn_from_julian(year: i64, month: u32, day: u32) -> i64 {
948    let a = (14 - month as i64) / 12;
949    let y = year + 4800 - a;
950    let m = month as i64 + 12 * a - 3;
951    day as i64 + (153 * m + 2) / 5 + 365 * y + y / 4 - 32083
952}
953
954/// The inverse of [`jdn_from_julian`]: the **Julian-calendar** `(year, month, day)` of a JDN.
955pub fn julian_from_jdn(jdn: i64) -> (i64, u32, u32) {
956    let c = jdn + 32082;
957    let d = (4 * c + 3) / 1461;
958    let e = c - (1461 * d) / 4;
959    let m = (5 * e + 2) / 153;
960    let day = (e - (153 * m + 2) / 5 + 1) as u32;
961    let month = (m + 3 - 12 * (m / 10)) as u32;
962    let year = d - 4800 + m / 10;
963    (year, month, day)
964}
965
966/// Days since 1970-01-01 for a **Julian-calendar** date.
967pub fn days_from_julian(year: i64, month: u32, day: u32) -> i64 {
968    jdn_from_julian(year, month, day) - UNIX_EPOCH_JDN
969}
970
971/// The inverse: the **Julian-calendar** `(year, month, day)` of a day number.
972pub fn julian_from_days(z: i64) -> (i64, u32, u32) {
973    julian_from_jdn(z + UNIX_EPOCH_JDN)
974}
975
976#[cfg(test)]
977mod tests {
978    use super::*;
979
980    /// A tiny deterministic RNG (SplitMix64) for reproducible fuzz.
981    struct Rng(u64);
982    impl Rng {
983        fn next(&mut self) -> u64 {
984            self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
985            let mut z = self.0;
986            z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
987            z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
988            z ^ (z >> 31)
989        }
990    }
991
992    #[test]
993    fn local_instant_encodes_the_wall_clock_for_zoned_component_reads() {
994        // The local-as-UTC instant, read with the plain UTC calendar, gives the zone's wall clock.
995        let read = |ts: &str, zone: &str| {
996            let inst = parse_rfc3339(ts).unwrap();
997            let local = local_instant_nanos(inst, zone).unwrap();
998            civil_from_unix_nanos(local)
999        };
1000        // 2024-07-01T12:00Z in New York = EDT (-4) → 08:00 local, same day.
1001        let ny = read("2024-07-01T12:00:00Z", "America/New_York");
1002        assert_eq!((ny.hour, ny.day, ny.month), (8, 1, 7));
1003        // 2024-01-01T00:00Z in Kolkata (+5:30) → 05:30 local.
1004        let ist = read("2024-01-01T00:00:00Z", "Asia/Kolkata");
1005        assert_eq!((ist.hour, ist.minute, ist.day), (5, 30, 1));
1006        // 2024-07-01T02:00Z in New York (-4) → previous calendar day, 22:00 local (date rollover).
1007        let roll = read("2024-07-01T02:00:00Z", "America/New_York");
1008        assert_eq!((roll.hour, roll.day, roll.month), (22, 30, 6));
1009        // An unknown zone yields None, never a panic.
1010        assert_eq!(local_instant_nanos(0, "Mars/Olympus_Mons"), None);
1011    }
1012
1013    #[test]
1014    fn calendar_months_and_years_between_count_complete_periods() {
1015        let at = |ts: &str| parse_rfc3339(ts).unwrap();
1016        let months = |a: &str, b: &str| months_between(at(a), at(b));
1017        let years = |a: &str, b: &str| years_between(at(a), at(b));
1018
1019        // Whole and partial months.
1020        assert_eq!(months("2024-01-15T00:00:00Z", "2024-03-15T00:00:00Z"), 2);
1021        assert_eq!(months("2024-01-15T00:00:00Z", "2024-03-14T00:00:00Z"), 1); // a day short
1022        // End-of-month clamp: Jan 31 → Feb 28 (2023, non-leap) IS a complete month.
1023        assert_eq!(months("2023-01-31T00:00:00Z", "2023-02-28T00:00:00Z"), 1);
1024        assert_eq!(months("2023-01-31T00:00:00Z", "2023-02-27T00:00:00Z"), 0);
1025        // Time-of-day precision: one hour short of a month is not a complete month.
1026        assert_eq!(months("2024-01-15T12:00:00Z", "2024-02-15T11:00:00Z"), 0);
1027        assert_eq!(months("2024-01-15T12:00:00Z", "2024-02-15T12:00:00Z"), 1);
1028        // Signed / antisymmetric.
1029        assert_eq!(months("2024-03-15T00:00:00Z", "2024-01-15T00:00:00Z"), -2);
1030        assert_eq!(months("2020-01-01T00:00:00Z", "2020-01-01T00:00:00Z"), 0);
1031
1032        // Complete years are complete 12-month periods.
1033        assert_eq!(years("2020-06-01T00:00:00Z", "2024-06-01T00:00:00Z"), 4);
1034        assert_eq!(years("2020-06-01T00:00:00Z", "2024-03-01T00:00:00Z"), 3); // 4th year not reached
1035        assert_eq!(years("2020-06-01T00:00:00Z", "2024-05-31T00:00:00Z"), 3);
1036        assert_eq!(years("2024-01-01T00:00:00Z", "2020-01-01T00:00:00Z"), -4);
1037    }
1038
1039    #[test]
1040    fn time_of_day_formats_losslessly_to_the_nanosecond() {
1041        let h = 3_600 * NANOS_PER_SECOND;
1042        let m = 60 * NANOS_PER_SECOND;
1043        let s = NANOS_PER_SECOND;
1044        assert_eq!(format_time_of_day(0), "00:00:00");
1045        assert_eq!(format_time_of_day(16 * h), "16:00:00"); // 4pm
1046        assert_eq!(format_time_of_day(7 * h + 30 * m + 45 * s), "07:30:45");
1047        assert_eq!(format_time_of_day(23 * h + 59 * m + 59 * s), "23:59:59");
1048        // Sub-second fraction only when nonzero, trailing zeros trimmed.
1049        assert_eq!(format_time_of_day(7 * h + 30 * m + 45 * s + 500_000_000), "07:30:45.5");
1050        assert_eq!(format_time_of_day(123_456_789), "00:00:00.123456789");
1051        // Normalised modulo a day: a full day wraps to midnight, a negative is the pre-midnight clock.
1052        assert_eq!(format_time_of_day(NANOS_PER_DAY), "00:00:00");
1053        assert_eq!(format_time_of_day(-1), "23:59:59.999999999");
1054    }
1055
1056    #[test]
1057    fn gregorian_known_anchors() {
1058        // The Unix epoch is day 0, a Thursday.
1059        assert_eq!(days_from_civil(1970, 1, 1), 0);
1060        assert_eq!(civil_from_days(0), (1970, 1, 1));
1061        assert_eq!(weekday_from_days(0), 4); // Thursday
1062        assert_eq!(iso_weekday_from_days(0), 4); // Thursday = ISO 4
1063        // Y2K: 2000-01-01 was a Saturday, day 10957.
1064        assert_eq!(days_from_civil(2000, 1, 1), 10957);
1065        assert_eq!(weekday_from_days(10957), 6); // Saturday
1066        // A day before the epoch.
1067        assert_eq!(days_from_civil(1969, 12, 31), -1);
1068        assert_eq!(civil_from_days(-1), (1969, 12, 31));
1069    }
1070
1071    #[test]
1072    fn gregorian_leap_years_and_month_lengths() {
1073        assert!(is_gregorian_leap(2000)); // divisible by 400
1074        assert!(!is_gregorian_leap(1900)); // century, not by 400
1075        assert!(is_gregorian_leap(2024));
1076        assert!(!is_gregorian_leap(2023));
1077        assert_eq!(last_day_of_month(2024, 2), 29);
1078        assert_eq!(last_day_of_month(2023, 2), 28);
1079        assert_eq!(last_day_of_month(2024, 1), 31);
1080        assert_eq!(last_day_of_month(2024, 4), 30);
1081        // Day-of-year: 2024-12-31 is day 366 (leap), 2023-12-31 is day 365.
1082        assert_eq!(day_of_year(2024, 12, 31), 366);
1083        assert_eq!(day_of_year(2023, 12, 31), 365);
1084        assert_eq!(day_of_year(2024, 3, 1), 61); // 31 + 29 + 1
1085    }
1086
1087    #[test]
1088    fn iso_week_date_edge_cases() {
1089        // 2021-01-01 (Friday) belongs to ISO week 53 of 2020.
1090        assert_eq!(iso_week_from_days(days_from_civil(2021, 1, 1)), (2020, 53, 5));
1091        // 2020-12-31 (Thursday) is ISO 2020-W53-4.
1092        assert_eq!(iso_week_from_days(days_from_civil(2020, 12, 31)), (2020, 53, 4));
1093        // 2023-01-01 (Sunday) is ISO 2022-W52-7.
1094        assert_eq!(iso_week_from_days(days_from_civil(2023, 1, 1)), (2022, 52, 7));
1095        // 2024-01-01 (Monday) starts ISO 2024-W01-1 cleanly.
1096        assert_eq!(iso_week_from_days(days_from_civil(2024, 1, 1)), (2024, 1, 1));
1097        // A 53-week ISO year: 2020 has 53 weeks; 2020-W53 exists.
1098        let (y, w, _) = iso_week_from_days(days_from_civil(2020, 12, 28)); // Monday of W53
1099        assert_eq!((y, w), (2020, 53));
1100    }
1101
1102    #[test]
1103    fn julian_gregorian_divergence() {
1104        // The Gregorian reform: 1582-10-15 Gregorian = 1582-10-05 Julian (the 10-day jump).
1105        let reform = days_from_civil(1582, 10, 15);
1106        assert_eq!(julian_from_days(reform), (1582, 10, 5));
1107        // In the 20th/21st century the Gregorian calendar runs 13 days ahead of Julian:
1108        // Gregorian 1970-01-01 is Julian 1969-12-19.
1109        assert_eq!(julian_from_days(days_from_civil(1970, 1, 1)), (1969, 12, 19));
1110        // Round the other way: Julian 1969-12-19 maps back to the same day number.
1111        assert_eq!(days_from_julian(1969, 12, 19), 0);
1112        // Julian leap rule differs: 1900 is a leap year in Julian, not in Gregorian.
1113        assert!(is_julian_leap(1900));
1114        assert!(!is_gregorian_leap(1900));
1115    }
1116
1117    #[test]
1118    fn gregorian_round_trips_over_a_wide_range_under_fuzz() {
1119        // Property: civil_from_days ∘ days_from_civil = identity for every day across ±400k days
1120        // (~±1100 years around the epoch), and the day number is strictly monotonic in date.
1121        let mut prev = i64::MIN;
1122        let mut rng = Rng(0x_DA7E_C0DE_1234_5678);
1123        for _ in 0..50_000 {
1124            let z = (rng.next() % 800_001) as i64 - 400_000;
1125            let (y, m, d) = civil_from_days(z);
1126            assert!((1..=12).contains(&m), "month in range at z={z}");
1127            assert!((1..=last_day_of_month(y, m)).contains(&d), "day in range at z={z}");
1128            assert_eq!(days_from_civil(y, m, d), z, "round-trip at z={z}");
1129            // Weekday cycles correctly: consecutive days differ by one weekday.
1130            assert_eq!(weekday_from_days(z + 1), (weekday_from_days(z) + 1) % 7);
1131            let _ = prev;
1132            prev = z;
1133        }
1134    }
1135
1136    #[test]
1137    fn smooth_utc_instant_civil_decomposition() {
1138        let dt = |y, mo, d, h, mi, s, n| CivilDateTime {
1139            year: y, month: mo, day: d, hour: h, minute: mi, second: s, nanosecond: n,
1140        };
1141        // The epoch.
1142        assert_eq!(civil_from_unix_nanos(0), dt(1970, 1, 1, 0, 0, 0, 0));
1143        assert_eq!(unix_nanos_from_civil(dt(1970, 1, 1, 0, 0, 0, 0)), 0);
1144        // One full day.
1145        assert_eq!(unix_nanos_from_civil(dt(1970, 1, 2, 0, 0, 0, 0)), NANOS_PER_DAY);
1146        // A precise instant with sub-second nanos.
1147        let stamp = dt(2024, 2, 29, 13, 45, 30, 123_456_789); // a leap-day afternoon
1148        assert_eq!(civil_from_unix_nanos(unix_nanos_from_civil(stamp)), stamp);
1149        // Pre-epoch: −1 ns is the last nanosecond of 1969.
1150        assert_eq!(
1151            civil_from_unix_nanos(-1),
1152            dt(1969, 12, 31, 23, 59, 59, 999_999_999)
1153        );
1154        // Time-of-day extraction: noon = 12:00:00.
1155        assert_eq!(civil_from_unix_nanos(12 * 3600 * NANOS_PER_SECOND).hour, 12);
1156    }
1157
1158    #[test]
1159    fn calendar_span_arithmetic_clamps_and_rolls_over() {
1160        let dt = |y, mo, d, h, mi, s, n| CivilDateTime {
1161            year: y, month: mo, day: d, hour: h, minute: mi, second: s, nanosecond: n,
1162        };
1163        // End-of-month clamping: Jan 31 + 1 month = Feb 28 (2023) / Feb 29 (2024).
1164        assert_eq!(add_span(dt(2023, 1, 31, 9, 0, 0, 0), 1, 0), dt(2023, 2, 28, 9, 0, 0, 0));
1165        assert_eq!(add_span(dt(2024, 1, 31, 9, 0, 0, 0), 1, 0), dt(2024, 2, 29, 9, 0, 0, 0));
1166        // Time-of-day is preserved across a span.
1167        assert_eq!(add_span(dt(2024, 3, 15, 13, 45, 30, 7), 0, 1), dt(2024, 3, 16, 13, 45, 30, 7));
1168        // Year rollover: Dec + 1 month = next Jan.
1169        assert_eq!(add_span(dt(2024, 12, 10, 0, 0, 0, 0), 1, 0), dt(2025, 1, 10, 0, 0, 0, 0));
1170        // Days roll over months: Jan 15 + 20 days = Feb 4.
1171        assert_eq!(add_span(dt(2024, 1, 15, 0, 0, 0, 0), 0, 20), dt(2024, 2, 4, 0, 0, 0, 0));
1172        // Negative months: Mar 31 − 1 month = Feb 29 (clamped, leap year).
1173        assert_eq!(add_span(dt(2024, 3, 31, 0, 0, 0, 0), -1, 0), dt(2024, 2, 29, 0, 0, 0, 0));
1174        // +12 months = same date next year (a clean year).
1175        assert_eq!(add_span(dt(2024, 6, 15, 0, 0, 0, 0), 12, 0), dt(2025, 6, 15, 0, 0, 0, 0));
1176    }
1177
1178    #[test]
1179    fn business_day_math() {
1180        let d = |y, mo, day| days_from_civil(y, mo, day);
1181        // 2024-03-11 is a Monday; 2024-03-18 the next Monday → 5 business days between.
1182        assert_eq!(business_days_between(d(2024, 3, 11), d(2024, 3, 18)), 5);
1183        // Within one week, Mon→Fri is 4 business days; the weekend adds none.
1184        assert_eq!(business_days_between(d(2024, 3, 11), d(2024, 3, 15)), 4);
1185        assert_eq!(business_days_between(d(2024, 3, 15), d(2024, 3, 18)), 1); // Fri→Mon (skips weekend)
1186        // Direction is signed.
1187        assert_eq!(business_days_between(d(2024, 3, 18), d(2024, 3, 11)), -5);
1188        // Friday + 1 business day = Monday; Monday − 1 = previous Friday.
1189        assert_eq!(add_business_days(d(2024, 3, 15), 1), d(2024, 3, 18)); // Fri → Mon
1190        assert_eq!(add_business_days(d(2024, 3, 18), -1), d(2024, 3, 15)); // Mon → Fri
1191        // Five business days from Monday lands on the next Monday.
1192        assert_eq!(add_business_days(d(2024, 3, 11), 5), d(2024, 3, 18));
1193        // Weekends are weekends.
1194        assert!(is_weekend(d(2024, 3, 16))); // Saturday
1195        assert!(is_weekend(d(2024, 3, 17))); // Sunday
1196        assert!(!is_weekend(d(2024, 3, 15))); // Friday
1197    }
1198
1199    #[test]
1200    fn business_day_invariants_under_fuzz() {
1201        // Properties (from a WEEKDAY anchor, where business-day counting is unambiguous):
1202        // advancing n business days never lands on a weekend, and the business-day count from the
1203        // anchor to its n-later image is exactly n. `n > 0` advances strictly into the future.
1204        let mut rng = Rng(0x_B17A_DA75_C0DE_2468);
1205        for _ in 0..20_000 {
1206            let z0 = (rng.next() % 200_001) as i64 - 100_000;
1207            let z = if is_weekend(z0) { add_business_days(z0, 1) } else { z0 }; // a weekday
1208            let n = (rng.next() % 39) as i64 + 1; // 1..=39 business days
1209            let fwd = add_business_days(z, n);
1210            assert!(!is_weekend(fwd), "lands on a business day at z={z}, n={n}");
1211            assert!(fwd > z, "advancing moves strictly forward at z={z}, n={n}");
1212            assert_eq!(business_days_between(z, fwd), n, "count matches at z={z}, n={n}");
1213        }
1214    }
1215
1216    /// A hand-built US-Eastern-style zone for 2024: EST = UTC−5h, EDT = UTC−4h.
1217    /// Spring forward 2024-03-10 07:00 UTC (02:00 EST → 03:00 EDT); fall back 2024-11-03 06:00 UTC
1218    /// (02:00 EDT → 01:00 EST).
1219    fn eastern_2024() -> (Vec<ZoneTransition>, i32) {
1220        let spring = days_from_civil(2024, 3, 10) * SECONDS_PER_DAY + 7 * 3600;
1221        let fall = days_from_civil(2024, 11, 3) * SECONDS_PER_DAY + 6 * 3600;
1222        (
1223            vec![
1224                ZoneTransition { at_unix_seconds: spring, offset_seconds: -4 * 3600 },
1225                ZoneTransition { at_unix_seconds: fall, offset_seconds: -5 * 3600 },
1226            ],
1227            -5 * 3600, // base: EST
1228        )
1229    }
1230
1231    fn utc(y: i64, mo: u32, d: u32, h: u32, mi: u32) -> i64 {
1232        unix_nanos_from_civil(CivilDateTime { year: y, month: mo, day: d, hour: h, minute: mi, second: 0, nanosecond: 0 })
1233    }
1234
1235    #[test]
1236    fn timezone_utc_to_local_picks_the_offset_in_effect() {
1237        let (tz, base) = eastern_2024();
1238        let local = |ns| {
1239            let c = to_local(&tz, base, ns);
1240            (c.month, c.day, c.hour, c.minute)
1241        };
1242        // Winter → EST (−5): 2024-01-01 12:00 UTC = 07:00 local.
1243        assert_eq!(local(utc(2024, 1, 1, 12, 0)), (1, 1, 7, 0));
1244        // Summer → EDT (−4): 2024-07-01 12:00 UTC = 08:00 local.
1245        assert_eq!(local(utc(2024, 7, 1, 12, 0)), (7, 1, 8, 0));
1246        // Just after fall-back → back to EST: 2024-12-01 12:00 UTC = 07:00 local.
1247        assert_eq!(local(utc(2024, 12, 1, 12, 0)), (12, 1, 7, 0));
1248        // The offset function directly.
1249        assert_eq!(offset_at(&tz, base, utc(2024, 7, 1, 0, 0)), -4 * 3600);
1250        assert_eq!(offset_at(&tz, base, utc(2024, 1, 1, 0, 0)), -5 * 3600);
1251    }
1252
1253    #[test]
1254    fn timezone_local_to_utc_round_trip_and_dst_fold() {
1255        let (tz, base) = eastern_2024();
1256        let cdt = |y, mo, d, h, mi| CivilDateTime {
1257            year: y, month: mo, day: d, hour: h, minute: mi, second: 0, nanosecond: 0,
1258        };
1259        // Unambiguous local → UTC round-trips: 08:00 EDT on 2024-07-01 = 12:00 UTC.
1260        let summer_local = cdt(2024, 7, 1, 8, 0);
1261        let summer_utc = from_local(&tz, base, summer_local, Fold::Later);
1262        assert_eq!(summer_utc, utc(2024, 7, 1, 12, 0));
1263        assert_eq!(to_local(&tz, base, summer_utc), summer_local);
1264        // DST FOLD: 2024-11-03 01:30 local occurs twice. Earlier = 05:30 UTC (still EDT −4),
1265        // Later = 06:30 UTC (now EST −5).
1266        let ambiguous = cdt(2024, 11, 3, 1, 30);
1267        assert_eq!(from_local(&tz, base, ambiguous, Fold::Earlier), utc(2024, 11, 3, 5, 30));
1268        assert_eq!(from_local(&tz, base, ambiguous, Fold::Later), utc(2024, 11, 3, 6, 30));
1269        // DST GAP: 2024-03-10 02:30 local never occurs (clocks jump 02:00→03:00). Resolution is
1270        // deterministic and the two folds differ (boundary instants of the skipped hour).
1271        let nonexistent = cdt(2024, 3, 10, 2, 30);
1272        let gap_earlier = from_local(&tz, base, nonexistent, Fold::Earlier);
1273        let gap_later = from_local(&tz, base, nonexistent, Fold::Later);
1274        assert_ne!(gap_earlier, gap_later, "the two gap resolutions differ");
1275    }
1276
1277    #[test]
1278    fn dst_rule_evaluator_generates_correct_transitions() {
1279        // Nth-weekday anchors.
1280        assert_eq!(nth_weekday_of_month(2024, 3, 2, 0), 10); // 2nd Sunday of March 2024
1281        assert_eq!(nth_weekday_of_month(2024, 11, 1, 0), 3); // 1st Sunday of November 2024
1282        assert_eq!(nth_weekday_of_month(2024, 10, 5, 0), 27); // last Sunday of October 2024 (EU rule)
1283        assert_eq!(nth_weekday_of_month(2024, 2, 5, 4), 29); // last Thursday of Feb 2024 (leap)
1284        // The US Eastern POSIX rule `EST5EDT,M3.2.0,M11.1.0` generates exactly the hand-built 2024
1285        // transitions (spring 2024-03-10 07:00Z → EDT, fall 2024-11-03 06:00Z → EST).
1286        let start = DstRule { month: 3, week: 2, weekday: 0, time_seconds: 2 * 3600 };
1287        let end = DstRule { month: 11, week: 1, weekday: 0, time_seconds: 2 * 3600 };
1288        let gen = dst_transitions_for_year(-5 * 3600, -4 * 3600, start, end, 2024);
1289        let (hand, _) = eastern_2024();
1290        assert_eq!(gen.to_vec(), hand, "generated transitions match the hand-built zone");
1291        // The generated zone localizes correctly: summer = EDT (−4), winter = EST (−5).
1292        let tz = gen.to_vec();
1293        let hour = |y, mo, d| to_local(&tz, -5 * 3600, utc(y, mo, d, 12, 0)).hour;
1294        assert_eq!(hour(2024, 7, 1), 8); // 12:00Z → 08:00 EDT
1295        assert_eq!(hour(2024, 1, 1), 7); // 12:00Z → 07:00 EST
1296    }
1297
1298    #[test]
1299    fn nth_weekday_invariants_under_fuzz() {
1300        // Property: the result is a valid day of the month, has the requested weekday, and lies in
1301        // the requested week band (or is the last such weekday when week = 5).
1302        let mut rng = Rng(0x_4EE7_DA42_C0DE_9001);
1303        for _ in 0..20_000 {
1304            let year = 1900 + (rng.next() % 300) as i64;
1305            let month = (rng.next() % 12) as u32 + 1;
1306            let weekday = (rng.next() % 7) as u32;
1307            let week = (rng.next() % 5) as u32 + 1; // 1..=5
1308            let day = nth_weekday_of_month(year, month, week, weekday);
1309            assert!((1..=last_day_of_month(year, month)).contains(&day), "valid day {year}-{month} w{week}");
1310            assert_eq!(weekday_from_days(days_from_civil(year, month, day)), weekday, "weekday matches");
1311            // It is indeed the week-th (or last) such weekday: one week earlier is a different month
1312            // for week 1, or the prior occurrence; one week later overflows the month for "last".
1313            if week < 5 {
1314                assert_eq!((day - 1) / 7 + 1, week, "in the requested week band");
1315            } else {
1316                assert!(day + 7 > last_day_of_month(year, month), "week 5 is the last occurrence");
1317            }
1318        }
1319    }
1320
1321    #[test]
1322    fn format_zoned_shows_local_time_with_offset() {
1323        let u = |y, mo, d, h| {
1324            unix_nanos_from_civil(CivilDateTime { year: y, month: mo, day: d, hour: h, minute: 0, second: 0, nanosecond: 0 })
1325        };
1326        // Summer → EDT (−4); winter → EST (−5).
1327        assert_eq!(format_zoned(u(2024, 7, 1, 12), "America/New_York").as_deref(), Some("2024-07-01T08:00:00-04:00"));
1328        assert_eq!(format_zoned(u(2024, 1, 1, 12), "America/New_York").as_deref(), Some("2024-01-01T07:00:00-05:00"));
1329        // UTC and a half-hour positive offset.
1330        assert_eq!(format_zoned(u(2024, 1, 1, 12), "UTC").as_deref(), Some("2024-01-01T12:00:00+00:00"));
1331        assert_eq!(format_zoned(u(2024, 1, 1, 0), "Asia/Kolkata").as_deref(), Some("2024-01-01T05:30:00+05:30"));
1332        // Unknown zone → None.
1333        assert_eq!(format_zoned(0, "Mars/Base"), None);
1334    }
1335
1336    #[test]
1337    fn named_zone_registry_localizes_with_dst() {
1338        let h = |z: &ZoneSpec, y, mo, d, hh| z.to_local(utc(y, mo, d, hh, 0)).hour;
1339        // America/New_York: EDT (−4) in summer, EST (−5) in winter.
1340        let ny = zone_by_name("America/New_York").unwrap();
1341        assert_eq!(h(&ny, 2024, 7, 1, 12), 8); // 12:00Z → 08:00 EDT
1342        assert_eq!(h(&ny, 2024, 1, 1, 12), 7); // 12:00Z → 07:00 EST
1343        // UTC: fixed, no DST.
1344        let u = zone_by_name("UTC").unwrap();
1345        assert!(u.dst.is_none());
1346        assert_eq!(h(&u, 2024, 7, 1, 12), 12);
1347        // Europe/London: BST (+1) in summer, GMT (0) in winter.
1348        let lon = zone_by_name("Europe/London").unwrap();
1349        assert_eq!(h(&lon, 2024, 7, 1, 12), 13); // BST
1350        assert_eq!(h(&lon, 2024, 1, 1, 12), 12); // GMT
1351        // Asia/Kolkata: fixed +5:30.
1352        let ist = zone_by_name("Asia/Kolkata").unwrap();
1353        let c = ist.to_local(utc(2024, 1, 1, 0, 0));
1354        assert_eq!((c.hour, c.minute), (5, 30));
1355        // Australia/Sydney (southern hemisphere): AEDT (+11) in January, AEST (+10) in July.
1356        let syd = zone_by_name("Australia/Sydney").unwrap();
1357        assert_eq!(h(&syd, 2024, 1, 1, 0), 11); // 00:00Z → 11:00 AEDT (summer down under)
1358        assert_eq!(h(&syd, 2024, 7, 1, 0), 10); // 00:00Z → 10:00 AEST (winter)
1359        // Unknown zone → None.
1360        assert!(zone_by_name("Mars/Olympus_Mons").is_none());
1361    }
1362
1363    #[test]
1364    fn timezone_round_trips_unambiguous_locals_under_fuzz() {
1365        // Property: outside the fall-back fold hour, to_local ∘ from_local = identity. We test by
1366        // generating UTC instants (each maps to exactly one local), converting to local and back.
1367        let (tz, base) = eastern_2024();
1368        let mut rng = Rng(0x_72ED_C0DE_8888_4321);
1369        let year_start = utc(2024, 1, 1, 0, 0);
1370        for _ in 0..20_000 {
1371            let ns = year_start + (rng.next() % (300 * NANOS_PER_DAY as u64)) as i64;
1372            let local = to_local(&tz, base, ns);
1373            // from_local with the matching fold recovers the instant: pick whichever fold matches.
1374            let back_e = from_local(&tz, base, local, Fold::Earlier);
1375            let back_l = from_local(&tz, base, local, Fold::Later);
1376            assert!(
1377                back_e == ns || back_l == ns,
1378                "instant recovered from its local time (ns={ns}, e={back_e}, l={back_l})"
1379            );
1380        }
1381    }
1382
1383    #[test]
1384    fn rfc3339_format_and_parse_round_trip() {
1385        let at = |y, mo, d, h, mi, s, n| CivilDateTime {
1386            year: y, month: mo, day: d, hour: h, minute: mi, second: s, nanosecond: n,
1387        };
1388        // The epoch, no fraction.
1389        assert_eq!(format_rfc3339(0), "1970-01-01T00:00:00Z");
1390        assert_eq!(parse_rfc3339("1970-01-01T00:00:00Z"), Some(0));
1391        // Full nanosecond precision, round-trip.
1392        let ns = unix_nanos_from_civil(at(2024, 3, 10, 7, 30, 0, 123_456_789));
1393        assert_eq!(format_rfc3339(ns), "2024-03-10T07:30:00.123456789Z");
1394        assert_eq!(parse_rfc3339("2024-03-10T07:30:00.123456789Z"), Some(ns));
1395        // Trailing zeros trimmed: half a second is `.5`.
1396        let half = unix_nanos_from_civil(at(2000, 1, 1, 0, 0, 0, 500_000_000));
1397        assert_eq!(format_rfc3339(half), "2000-01-01T00:00:00.5Z");
1398        assert_eq!(parse_rfc3339("2000-01-01T00:00:00.5Z"), Some(half));
1399        // Numeric offsets normalize to the same UTC instant as 07:30Z.
1400        let utc_730 = unix_nanos_from_civil(at(2024, 3, 10, 7, 30, 0, 0));
1401        assert_eq!(parse_rfc3339("2024-03-10T03:30:00-04:00"), Some(utc_730));
1402        assert_eq!(parse_rfc3339("2024-03-10T13:00:00+05:30"), Some(utc_730));
1403        // Pre-epoch.
1404        assert_eq!(format_rfc3339(-NANOS_PER_SECOND), "1969-12-31T23:59:59Z");
1405        assert_eq!(parse_rfc3339("1969-12-31T23:59:59Z"), Some(-NANOS_PER_SECOND));
1406        // Malformed inputs are a clean None (never a panic).
1407        assert_eq!(parse_rfc3339("not a date"), None);
1408        assert_eq!(parse_rfc3339("2024-13-01T00:00:00Z"), None); // month out of range
1409        assert_eq!(parse_rfc3339("2024-02-30T00:00:00Z"), None); // day out of range
1410        assert_eq!(parse_rfc3339("2024-03-10 07:30:00Z"), None); // missing 'T'
1411        assert_eq!(parse_rfc3339("2024-03-10T07:30:00"), None); // missing zone
1412    }
1413
1414    #[test]
1415    fn duration_format_and_parse() {
1416        let s = NANOS_PER_SECOND;
1417        // Canonical forms.
1418        assert_eq!(format_duration(0), "0s");
1419        assert_eq!(format_duration(90 * 60 * s), "1h30m");
1420        assert_eq!(format_duration(2 * 86_400 * s), "2d");
1421        assert_eq!(format_duration(s + s / 2), "1.5s");
1422        assert_eq!(format_duration(s / 2), "500ms");
1423        assert_eq!(format_duration(-(90 * 60 * s)), "-1h30m");
1424        assert_eq!(format_duration(86_400 * s + 2 * 3600 * s + 3 * 60 * s + 4 * s), "1d2h3m4s");
1425        assert_eq!(format_duration(1500), "1.5us");
1426        assert_eq!(format_duration(42), "42ns");
1427        // Parse the canonical forms back.
1428        assert_eq!(parse_duration("1h30m"), Some(90 * 60 * s));
1429        assert_eq!(parse_duration("90m"), Some(90 * 60 * s)); // non-canonical but valid
1430        assert_eq!(parse_duration("1.5s"), Some(s + s / 2));
1431        assert_eq!(parse_duration("500ms"), Some(s / 2));
1432        assert_eq!(parse_duration("-1h"), Some(-(3600 * s)));
1433        assert_eq!(parse_duration("2d"), Some(2 * 86_400 * s));
1434        assert_eq!(parse_duration("1d2h3m4s"), Some(86_400 * s + 2 * 3600 * s + 3 * 60 * s + 4 * s));
1435        assert_eq!(parse_duration(".5s"), Some(s / 2)); // leading-dot fraction
1436        assert_eq!(parse_duration("1µs"), Some(1_000)); // micro sign accepted
1437        assert_eq!(parse_duration("250us"), Some(250_000));
1438        // Malformed → None (never a panic).
1439        assert_eq!(parse_duration(""), None);
1440        assert_eq!(parse_duration("abc"), None);
1441        assert_eq!(parse_duration("1x"), None); // unknown unit
1442        assert_eq!(parse_duration("h"), None); // no number
1443    }
1444
1445    #[test]
1446    fn duration_round_trips_under_fuzz() {
1447        // Property: parse_duration ∘ format_duration = identity for any duration.
1448        let mut rng = Rng(0x_D012_A710_C0DE_4444);
1449        for _ in 0..20_000 {
1450            let ns = (rng.next() as i64).wrapping_rem(9_000_000_000_000_000_000);
1451            assert_eq!(parse_duration(&format_duration(ns)), Some(ns), "round-trip {ns}");
1452        }
1453    }
1454
1455    #[test]
1456    fn rfc3339_round_trips_under_fuzz() {
1457        // Property: parse_rfc3339 ∘ format_rfc3339 = identity for any instant.
1458        let mut rng = Rng(0x_3339_C0DE_ABCD_1234);
1459        for _ in 0..20_000 {
1460            let ns = (rng.next() as i64).wrapping_rem(6_000_000_000_000_000_000);
1461            let s = format_rfc3339(ns);
1462            assert_eq!(parse_rfc3339(&s), Some(ns), "round-trip {s}");
1463        }
1464    }
1465
1466    #[test]
1467    fn sync_clock_merge_is_a_crdt_join() {
1468        use std::collections::BTreeMap;
1469        let mk = |hp: i64, node: u64, l: i64, u: i64| {
1470            let mut k = BTreeMap::new();
1471            k.insert(node, EstimateInterval::new(l, u));
1472            SyncClock { hlc: Hlc { physical_nanos: hp, logical: 0 }, knowledge: k }
1473        };
1474        let a = mk(100, 1, 10, 50);
1475        let b = mk(200, 1, 20, 40);
1476        let c = mk(150, 2, 5, 9);
1477        // CRDT laws.
1478        assert_eq!(a.merge(&a), a, "idempotent");
1479        assert_eq!(a.merge(&b), b.merge(&a), "commutative");
1480        assert_eq!(a.merge(&b).merge(&c), a.merge(&b.merge(&c)), "associative");
1481        // HLC takes the max; a shared node intersects; a distinct node is carried.
1482        let m = a.merge(&b);
1483        assert_eq!(m.hlc, Hlc { physical_nanos: 200, logical: 0 });
1484        assert_eq!(m.knowledge[&1], EstimateInterval::new(20, 40)); // [10,50] ∩ [20,40]
1485        let m2 = a.merge(&c);
1486        assert_eq!(m2.knowledge[&1], EstimateInterval::new(10, 50));
1487        assert_eq!(m2.knowledge[&2], EstimateInterval::new(5, 9));
1488    }
1489
1490    #[test]
1491    fn sync_clock_converges_regardless_of_order_and_intervals_only_tighten() {
1492        use std::collections::BTreeMap;
1493        let make = |hp: i64, node: u64, l: i64, u: i64| {
1494            let mut k = BTreeMap::new();
1495            k.insert(node, EstimateInterval::new(l, u));
1496            SyncClock { hlc: Hlc { physical_nanos: hp, logical: 0 }, knowledge: k }
1497        };
1498        let a = make(100, 1, 0, 100);
1499        let b = make(300, 1, 40, 80);
1500        // Two replicas exchange and converge to the SAME state, whichever order.
1501        assert_eq!(a.merge(&b), b.merge(&a));
1502        // Knowledge only tightens: the merged interval is no wider than either input.
1503        let merged_iv = a.merge(&b).knowledge[&1];
1504        assert!(merged_iv.width() <= a.knowledge[&1].width());
1505        assert!(merged_iv.width() <= b.knowledge[&1].width());
1506        // intersect itself is identity (idempotent at the interval level too).
1507        let iv = EstimateInterval::new(10, 90);
1508        assert_eq!(iv.intersect(&iv), iv);
1509        assert_eq!(iv.intersect(&EstimateInterval::new(30, 60)), EstimateInterval::new(30, 60));
1510    }
1511
1512    #[test]
1513    fn sync_clock_tick_observe_and_light_cone_horizon() {
1514        let c = SyncClock::new();
1515        let c2 = c.tick(500);
1516        assert!(c2.hlc > c.hlc, "tick advances causally");
1517        // observe records knowledge for a node.
1518        let c3 = c2.observe(7, EstimateInterval::new(100, 200));
1519        assert_eq!(c3.knowledge[&7], EstimateInterval::new(100, 200));
1520        // observing again tightens (intersects) rather than overwrites.
1521        let c4 = c3.observe(7, EstimateInterval::new(150, 250));
1522        assert_eq!(c4.knowledge[&7], EstimateInterval::new(150, 200));
1523        // You cannot know a node 0.2 s away fresher than now − 0.2 s.
1524        assert_eq!(knowable_horizon(1_000_000_000, 200_000_000), 800_000_000);
1525    }
1526
1527    #[test]
1528    fn hybrid_logical_clock_tick_and_receive() {
1529        let hlc = |p: i64, l: u32| Hlc { physical_nanos: p, logical: l };
1530        // First tick adopts wall time; a stalled clock advances the logical counter.
1531        assert_eq!(Hlc::ZERO.tick(100), hlc(100, 0));
1532        assert_eq!(hlc(100, 0).tick(100), hlc(100, 1)); // physical didn't move → logical++
1533        assert_eq!(hlc(100, 1).tick(200), hlc(200, 0)); // physical advanced → logical reset
1534        // A backward wall clock is absorbed by the logical counter (never goes back).
1535        assert_eq!(hlc(200, 0).tick(150), hlc(200, 1));
1536        // Receive: equal physicals → max(logical)+1.
1537        assert_eq!(hlc(100, 5).recv(hlc(100, 3), 100), hlc(100, 6));
1538        // Receive: remote ahead → take remote physical, remote.logical+1.
1539        assert_eq!(hlc(100, 5).recv(hlc(200, 2), 100), hlc(200, 3));
1540        // Receive: local ahead → local.logical+1.
1541        assert_eq!(hlc(200, 7).recv(hlc(100, 9), 150), hlc(200, 8));
1542        // Receive: wall time beats both → reset logical.
1543        assert_eq!(hlc(100, 5).recv(hlc(50, 9), 300), hlc(300, 0));
1544        // The derived Ord is the HLC total order.
1545        assert!(hlc(100, 0) < hlc(100, 1) && hlc(100, 1) < hlc(200, 0));
1546    }
1547
1548    #[test]
1549    fn hlc_preserves_causality_under_fuzz() {
1550        // Property: tick and recv always produce a timestamp strictly greater than every input —
1551        // so a send-then-receive chain is monotonic and happens-before is never inverted.
1552        let mut rng = Rng(0x_C0DE_C0FF_EE17_AAAA);
1553        let mut local = Hlc::ZERO;
1554        let mut remote = Hlc::ZERO;
1555        for _ in 0..20_000 {
1556            let now = (rng.next() % 1_000_000) as i64;
1557            if rng.next() & 1 == 0 {
1558                let next = local.tick(now);
1559                assert!(next > local, "tick advances");
1560                local = next;
1561            } else {
1562                let before = local;
1563                let next = local.recv(remote, now);
1564                assert!(next > before && next > remote, "recv dominates both inputs");
1565                local = next;
1566                remote = remote.tick((rng.next() % 1_000_000) as i64); // remote evolves independently
1567            }
1568        }
1569    }
1570
1571    #[test]
1572    fn leap_second_tai_utc_offsets() {
1573        let at = |y, mo, d| days_from_civil(y, mo, d) * SECONDS_PER_DAY;
1574        // Known TAI−UTC values from the IERS table.
1575        assert_eq!(tai_minus_utc(at(2017, 1, 1)), 37); // latest leap second
1576        assert_eq!(tai_minus_utc(at(2024, 6, 1)), 37); // unchanged since 2017
1577        assert_eq!(tai_minus_utc(at(1972, 1, 1)), 10); // table origin
1578        assert_eq!(tai_minus_utc(at(2000, 1, 1)), 32);
1579        assert_eq!(tai_minus_utc(at(1999, 1, 1)), 32); // the 1999-01-01 step
1580        assert_eq!(tai_minus_utc(at(1998, 12, 31)), 31); // the day before it
1581        assert_eq!(tai_minus_utc(at(1970, 1, 1)), 10); // pre-1972 floor
1582        // TAI is ahead of UTC by exactly the offset.
1583        assert_eq!(unix_to_tai_seconds(at(2017, 1, 1)), at(2017, 1, 1) + 37);
1584        assert_eq!(unix_to_tai_seconds(at(1972, 1, 1)), at(1972, 1, 1) + 10);
1585        // Round-trip away from a leap boundary.
1586        let t = at(2020, 6, 15) + 12_345;
1587        assert_eq!(tai_to_unix_seconds(unix_to_tai_seconds(t)), t);
1588        // TT = TAI + 32.184 s exactly.
1589        assert_eq!(TT_MINUS_TAI_NANOS, 32_184_000_000);
1590    }
1591
1592    #[test]
1593    fn tt_scale_is_exact() {
1594        let at_ns = |y, mo, d| days_from_civil(y, mo, d) * NANOS_PER_DAY;
1595        let u = at_ns(2017, 1, 1);
1596        // TAI = UTC + 37 s; TT = TAI + 32.184 s = UTC + 69.184 s.
1597        assert_eq!(tai_nanos_from_unix_nanos(u), u + 37 * NANOS_PER_SECOND);
1598        assert_eq!(tt_nanos_from_unix_nanos(u), u + 37 * NANOS_PER_SECOND + TT_MINUS_TAI_NANOS);
1599        // 1972: TAI−UTC = 10 → TT = UTC + 42.184 s.
1600        let u72 = at_ns(1972, 1, 1);
1601        assert_eq!(tt_nanos_from_unix_nanos(u72), u72 + 10 * NANOS_PER_SECOND + TT_MINUS_TAI_NANOS);
1602        // Sub-second precision survives, round-trip.
1603        let t = at_ns(2020, 3, 15) + 12_345_678_900;
1604        assert_eq!(unix_nanos_from_tt_nanos(tt_nanos_from_unix_nanos(t)), t);
1605    }
1606
1607    #[test]
1608    fn tt_round_trips_under_fuzz() {
1609        let mut rng = Rng(0x_77_C0DE_5CA1_9999);
1610        let base = days_from_civil(1972, 6, 1) * NANOS_PER_DAY;
1611        for _ in 0..20_000 {
1612            let ns = base + (rng.next() % 1_600_000_000_000_000_000) as i64;
1613            assert_eq!(unix_nanos_from_tt_nanos(tt_nanos_from_unix_nanos(ns)), ns, "TT round-trip at {ns}");
1614        }
1615    }
1616
1617    #[test]
1618    fn tai_round_trips_under_fuzz() {
1619        // Property: tai_to_unix_seconds ∘ unix_to_tai_seconds = identity (instants are not on a
1620        // leap-second boundary at whole-second granularity away from midnight steps).
1621        let mut rng = Rng(0x_7A1_C0DE_5EC0_4242);
1622        let base = days_from_civil(1972, 6, 1) * SECONDS_PER_DAY;
1623        for _ in 0..20_000 {
1624            let secs = base + (rng.next() % 1_700_000_000) as i64; // ~1972..2025
1625            assert_eq!(tai_to_unix_seconds(unix_to_tai_seconds(secs)), secs, "TAI round-trip at {secs}");
1626        }
1627    }
1628
1629    #[test]
1630    fn smooth_utc_round_trips_over_a_wide_range_under_fuzz() {
1631        // Property: civil_from_unix_nanos ∘ unix_nanos_from_civil = identity, and the decomposed
1632        // fields are always in civil range. SmoothUTC = no leap seconds, so seconds never hit 60.
1633        let mut rng = Rng(0x_5007_8807_C0DE_1357);
1634        for _ in 0..50_000 {
1635            // Range: ~±200 years of nanoseconds around the epoch (within i64).
1636            let ns = (rng.next() as i64).wrapping_rem(6_000_000_000_000_000_000);
1637            let c = civil_from_unix_nanos(ns);
1638            assert!((1..=12).contains(&c.month));
1639            assert!((1..=last_day_of_month(c.year, c.month)).contains(&c.day));
1640            assert!(c.hour < 24 && c.minute < 60 && c.second < 60 && c.nanosecond < 1_000_000_000);
1641            assert_eq!(unix_nanos_from_civil(c), ns, "instant round-trip at ns={ns}");
1642        }
1643    }
1644
1645    #[test]
1646    fn iso_week_round_trips_over_a_wide_range_under_fuzz() {
1647        // Property: days_from_iso_week ∘ iso_week_from_days = identity, the week is always 1..=53,
1648        // the ISO weekday always 1..=7, and the ISO weekday agrees with the plain weekday.
1649        let mut rng = Rng(0x_C0DE_FACE_8601_9999);
1650        for _ in 0..50_000 {
1651            let z = (rng.next() % 800_001) as i64 - 400_000;
1652            let (iy, w, dow) = iso_week_from_days(z);
1653            assert!((1..=53).contains(&w), "ISO week in range at z={z}: {w}");
1654            assert!((1..=7).contains(&dow), "ISO weekday in range at z={z}: {dow}");
1655            // ISO weekday (1=Mon..7=Sun) lines up with the plain weekday (0=Sun..6=Sat).
1656            let plain = weekday_from_days(z);
1657            assert_eq!(dow % 7, plain, "ISO/plain weekday agree at z={z}");
1658            // Reconstruct the exact day from its ISO week date.
1659            assert_eq!(days_from_iso_week(iy, w, dow), z, "ISO round-trip at z={z}");
1660        }
1661    }
1662
1663    #[test]
1664    fn julian_round_trips_over_a_wide_range_under_fuzz() {
1665        // Property: julian_from_days ∘ days_from_julian = identity, and the proleptic Julian
1666        // calendar is internally consistent (every produced (y,m,d) maps back to the day number).
1667        let mut rng = Rng(0x_1234_ABCD_5678_EF90);
1668        for _ in 0..50_000 {
1669            let z = (rng.next() % 800_001) as i64 - 400_000;
1670            let (y, m, d) = julian_from_days(z);
1671            assert!((1..=12).contains(&m), "julian month in range at z={z}");
1672            assert!((1..=31).contains(&d), "julian day in range at z={z}");
1673            assert_eq!(days_from_julian(y, m, d), z, "julian round-trip at z={z}");
1674        }
1675    }
1676}