Skip to main content

runtime/
clock.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! ClockStamp: pure formatting of an injected instant against
7//! already-resolved zone offsets, and the one spelling of a moment a
8//! person or a model reads: ISO 8601, UTC, to the second. Nothing here
9//! samples a clock; the calendar is integer arithmetic so replay and
10//! live share one algorithm and no tz database.
11//!
12//! Emission (`StampGate`): `Off` emits never (A18 zero-byte); the first
13//! result of a run emits once; `Timestamped` tools emit every result;
14//! `Timeless` tools emit only when the granularity bucket changed. The
15//! bucket decides how often, never what a stamp says: a stamp always
16//! carries the reading it was given.
17//!
18//! [`ClockReading`] is how a tool face learns what time it is without a
19//! clock of its own (`crates/runtime/spec/Clock.lean` §8-53).
20
21use std::sync::{Arc, Mutex, PoisonError};
22
23use kernel::consts_policy::CLOCK_ZONES_MAX;
24use kernel::{AxCode, AxError, ClockStampGranularity, ClockZone, Temporal, TimeMs};
25
26/// One instant in ISO 8601, UTC, to the second:
27/// `2026-05-14T09:31:07Z`. The clock line and `status` both write a
28/// moment through this; a reader that parses one back belongs beside it.
29pub fn iso(at: TimeMs) -> String {
30    format_at(at, 0)
31}
32
33/// The moment `raw` spells in the one shape [`iso`] writes,
34/// `2026-05-14T09:31:07Z`: UTC, to the second. The two are inverses:
35/// `parse_iso(&iso(t))` is `t` with its milliseconds dropped, and
36/// `iso(parse_iso(s)?)` is `s`.
37///
38/// # Errors
39/// `E_INVALID_ARGS` for every other spelling - a zone offset, a fraction
40/// of a second, a date alone, a lowercase `t` or `z`, a day the calendar
41/// does not have, `24:00:00`, a leap second, a moment before 1970 - with
42/// a recovery that shows the shape (`crates/runtime/spec/Clock.lean` §8-10).
43pub fn parse_iso(raw: &str) -> Result<TimeMs, AxError> {
44    let refused = || {
45        AxError::failure(AxCode::InvalidArgs, "read a UTC moment", raw).with_recovery(
46            "write the moment in UTC to the second, ending in Z, the way a clock line \
47             does: 2026-05-14T09:31:07Z; turn a local time or an offset into UTC first",
48        )
49    };
50    let millis = civil_fields(raw.as_bytes())
51        .and_then(millis_of)
52        .ok_or_else(refused)?;
53    u64::try_from(millis)
54        .map(TimeMs::new)
55        .map_err(|_before_1970| refused())
56}
57
58/// Where each separator of `YYYY-MM-DDTHH:MM:SSZ` stands, and each of
59/// its six numbers.
60const ISO_SEPARATORS: [(usize, u8); 6] = [
61    (4, b'-'),
62    (7, b'-'),
63    (10, b'T'),
64    (13, b':'),
65    (16, b':'),
66    (19, b'Z'),
67];
68const ISO_FIELDS: [(usize, usize); 6] = [(0, 4), (5, 7), (8, 10), (11, 13), (14, 16), (17, 19)];
69
70/// Year, month, day, hour, minute and second, when `bytes` is exactly
71/// `YYYY-MM-DDTHH:MM:SSZ` with ASCII digits.
72fn civil_fields(bytes: &[u8]) -> Option<[i128; 6]> {
73    let shaped = bytes.len() == 20
74        && ISO_SEPARATORS
75            .iter()
76            .all(|(at, separator)| bytes.get(*at) == Some(separator));
77    if !shaped {
78        return None;
79    }
80    let [year, month, day, hour, minute, second] = ISO_FIELDS.map(|(from, to)| {
81        bytes.get(from..to)?.iter().try_fold(0i128, |number, byte| {
82            let digit = char::from(*byte).to_digit(10)?;
83            number.checked_mul(10)?.checked_add(i128::from(digit))
84        })
85    });
86    Some([year?, month?, day?, hour?, minute?, second?])
87}
88
89/// Milliseconds since 1970 of a civil UTC moment, when the calendar has
90/// that day and the clock that second. Whether the day exists is asked
91/// of `civil_from_days`, so the calendar has one algorithm.
92#[expect(
93    clippy::arithmetic_side_effects,
94    reason = "every field is at most four decimal digits, so the day count and the \
95              milliseconds stay far inside i128; the divisors are positive constants"
96)]
97fn millis_of([year, month, day, hour, minute, second]: [i128; 6]) -> Option<i128> {
98    if !(1..=12).contains(&month) || hour > 23 || minute > 59 || second > 59 {
99        return None;
100    }
101    let y = if month <= 2 { year - 1 } else { year };
102    let era = y.div_euclid(400);
103    let yoe = y - era * 400;
104    let mp = if month > 2 { month - 3 } else { month + 9 };
105    let doy = (153 * mp + 2) / 5 + day - 1;
106    let days = era * 146_097 + yoe * 365 + yoe / 4 - yoe / 100 + doy - 719_468;
107    (civil_from_days(days) == (year, month, day))
108        .then_some((days * 86_400 + hour * 3_600 + minute * 60 + second) * 1_000)
109}
110
111/// The moments a selection by time keeps: `[since, until)` in UTC, an
112/// absent end left open (`crates/runtime/spec/Clock.lean` §8-57).
113///
114/// It judges one moment at a time and assumes no order: a ledger's `t`
115/// does not rise with `seq`, so a reader asks about every line and never
116/// stops at the first one past `until`.
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
118pub struct UtcSpan {
119    since: Option<TimeMs>,
120    until: Option<TimeMs>,
121}
122
123impl UtcSpan {
124    /// # Errors
125    /// `E_INVALID_ARGS` when `until` is not after `since`: such a span
126    /// holds no moment, and selecting by it would read as a stretch of
127    /// history in which nothing happened.
128    pub fn new(since: Option<TimeMs>, until: Option<TimeMs>) -> Result<UtcSpan, AxError> {
129        if let (Some(start), Some(end)) = (since, until)
130            && end <= start
131        {
132            return Err(AxError::failure(
133                AxCode::InvalidArgs,
134                "select a span of time",
135                format!("{} to {}", iso(start), iso(end)),
136            )
137            .with_recovery(
138                "give an end after the start; the end itself is left out, so a span that \
139                 ends where it starts holds no moment",
140            ));
141        }
142        Ok(UtcSpan { since, until })
143    }
144
145    #[must_use]
146    pub fn since(&self) -> Option<TimeMs> {
147        self.since
148    }
149
150    #[must_use]
151    pub fn until(&self) -> Option<TimeMs> {
152        self.until
153    }
154
155    /// Whether `at` is at or after `since` and before `until`.
156    #[must_use]
157    pub fn contains(&self, at: TimeMs) -> bool {
158        self.since.is_none_or(|since| since <= at) && self.until.is_none_or(|until| at < until)
159    }
160}
161
162/// One configured zone's row: the offset it was computed from and the
163/// same second in that zone, `2026-05-14T18:31:07+09:00`.
164#[derive(Debug, Clone, PartialEq, Eq)]
165pub struct ZoneEntry {
166    pub id: String,
167    pub offset_min: i32,
168    pub local: String,
169}
170
171/// The stamp a result envelope carries: the reading itself, and the
172/// rows of the zones the run was frozen with (none, in a real city:
173/// the city refuses `[clock] zones`, `crates/city/Spec.lean` §8-31).
174#[derive(Debug, Clone, PartialEq, Eq)]
175pub struct ClockStamp {
176    pub utc_ms: TimeMs,
177    pub zones: Vec<ZoneEntry>,
178}
179
180impl ClockStamp {
181    /// The envelope's clock line: `clock: 2026-05-14T09:31:07Z;`, then
182    /// ` <id> <local>;` for each zone.
183    pub fn render(&self) -> String {
184        let mut out = format!("clock: {};", iso(self.utc_ms));
185        for zone in &self.zones {
186            out.push(' ');
187            out.push_str(&zone.id);
188            out.push(' ');
189            out.push_str(&zone.local);
190            out.push(';');
191        }
192        out
193    }
194}
195
196fn bucket_ms(granularity: ClockStampGranularity) -> Option<u64> {
197    match granularity {
198        ClockStampGranularity::Off => None,
199        ClockStampGranularity::Minute => Some(60_000),
200        ClockStampGranularity::FiveMinute => Some(300_000),
201        ClockStampGranularity::Hour => Some(3_600_000),
202    }
203}
204
205/// Civil date from days since 1970-01-01 (era-based integer algorithm).
206#[expect(
207    clippy::arithmetic_side_effects,
208    reason = "era arithmetic on i128: the input is u64 milliseconds plus an i32 offset in \
209              minutes, divided down to days, far inside i128; every divisor is a positive \
210              constant, so neither overflow nor division by zero is reachable"
211)]
212fn civil_from_days(days: i128) -> (i128, i128, i128) {
213    let z = days + 719_468;
214    let era = if z >= 0 { z } else { z - 146_096 }.div_euclid(146_097);
215    let doe = z - era * 146_097;
216    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
217    let y = yoe + era * 400;
218    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
219    let mp = (5 * doy + 2) / 153;
220    let d = doy - (153 * mp + 2) / 5 + 1;
221    let m = if mp < 10 { mp + 3 } else { mp - 9 };
222    let year = if m <= 2 { y + 1 } else { y };
223    (year, m, d)
224}
225
226/// `at` in the zone `offset_min` minutes east of UTC, to the second,
227/// with `Z` for UTC itself and `+HH:MM` or `-HH:MM` otherwise.
228#[expect(
229    clippy::arithmetic_side_effects,
230    reason = "i128 values bounded by u64 milliseconds plus an i32 offset in minutes times \
231              60_000; div_euclid and rem_euclid by positive constants, so i128::MIN and \
232              division by zero are unreachable"
233)]
234fn format_at(at: TimeMs, offset_min: i32) -> String {
235    let shifted = i128::from(at.value()) + i128::from(offset_min) * 60_000;
236    let seconds = shifted.div_euclid(1_000);
237    let (year, month, day) = civil_from_days(seconds.div_euclid(86_400));
238    let second_of_day = seconds.rem_euclid(86_400);
239    let (hour, minute, second) = (
240        second_of_day / 3_600,
241        second_of_day % 3_600 / 60,
242        second_of_day % 60,
243    );
244    format!(
245        "{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}{}",
246        offset_suffix(offset_min)
247    )
248}
249
250fn offset_suffix(offset_min: i32) -> String {
251    if offset_min == 0 {
252        return "Z".to_owned();
253    }
254    let sign = if offset_min < 0 { '-' } else { '+' };
255    let whole = offset_min.unsigned_abs();
256    format!("{sign}{:02}:{:02}", whole / 60, whole % 60)
257}
258
259/// Formats one stamp of the reading `now` and every configured zone.
260/// Zones beyond `CLOCK_ZONES_MAX` are refused, not silently dropped.
261pub fn stamp(now: TimeMs, zones: &[ClockZone]) -> Result<ClockStamp, AxError> {
262    CLOCK_ZONES_MAX.admit(zones.len())?;
263    Ok(ClockStamp {
264        utc_ms: now,
265        zones: zones
266            .iter()
267            .map(|zone| ZoneEntry {
268                id: zone.id.clone(),
269                offset_min: zone.offset_min,
270                local: format_at(now, zone.offset_min),
271            })
272            .collect(),
273    })
274}
275
276/// Decides, per tool result, whether a stamp rides the envelope. One
277/// per run, built from the run's frozen configuration.
278#[derive(Debug)]
279pub struct StampGate {
280    granularity: ClockStampGranularity,
281    zones: Vec<ClockZone>,
282    last_bucket: Option<u64>,
283}
284
285impl StampGate {
286    /// `granularity` and `zones` are the run's `FrozenConfig`
287    /// `clock_stamp` and `clock_zones`.
288    pub fn new(granularity: ClockStampGranularity, zones: Vec<ClockZone>) -> StampGate {
289        StampGate {
290            granularity,
291            zones,
292            last_bucket: None,
293        }
294    }
295
296    /// The emission rule, in one place.
297    pub fn observe(
298        &mut self,
299        now: TimeMs,
300        temporal: Temporal,
301    ) -> Result<Option<ClockStamp>, AxError> {
302        let Some(width) = bucket_ms(self.granularity) else {
303            return Ok(None);
304        };
305        let bucket = now.value().checked_div(width).ok_or_else(|| {
306            AxError::failure(
307                AxCode::InvalidArgs,
308                "bucket clock stamp",
309                "zero bucket width",
310            )
311            .with_recovery(
312                "report this against runtime::clock: every granularity except the one \
313                 that shows no clock owes a bucket width above zero",
314            )
315        })?;
316        let due = match temporal {
317            Temporal::Timestamped => true,
318            Temporal::Timeless => self.last_bucket != Some(bucket),
319        } || self.last_bucket.is_none();
320        if !due {
321            return Ok(None);
322        }
323        self.last_bucket = Some(bucket);
324        stamp(now, &self.zones).map(Some)
325    }
326}
327
328/// The moment a run's driver read its clock last, shared with the tool
329/// faces that report the time (`crates/runtime/spec/Clock.lean` §8-53).
330///
331/// The driver's clock hook is the one sampling point of a run, and a
332/// tool face receives readings rather than sampling: the hook `keep`s
333/// each reading before handing it on, and a command's stamp or
334/// `status`'s `now:` line reads `latest`. What they report is therefore
335/// always some line's `t`. Cloned, it is one cell.
336///
337/// "Not read yet" is a state of its own rather than an integer standing
338/// for one. The lock holds one `Copy` value replaced whole, so a thread
339/// that panicked while holding it cannot have left half a value behind,
340/// and a poisoned lock is read like any other.
341#[derive(Debug, Clone, Default)]
342pub struct ClockReading(Arc<Mutex<Option<TimeMs>>>);
343
344impl ClockReading {
345    /// Records one reading the driver took.
346    pub fn keep(&self, at: TimeMs) {
347        *self.0.lock().unwrap_or_else(PoisonError::into_inner) = Some(at);
348    }
349
350    /// The last reading kept; `None` before the driver has read its
351    /// clock.
352    #[must_use]
353    pub fn latest(&self) -> Option<TimeMs> {
354        *self.0.lock().unwrap_or_else(PoisonError::into_inner)
355    }
356}
357
358#[cfg(test)]
359#[allow(
360    clippy::unwrap_used,
361    clippy::expect_used,
362    clippy::panic,
363    clippy::indexing_slicing,
364    reason = "test code"
365)]
366mod tests {
367    use super::*;
368
369    fn tokyo() -> ClockZone {
370        ClockZone {
371            id: "tokyo".to_owned(),
372            offset_min: 540,
373        }
374    }
375
376    fn rendered(at: u64, zones: &[ClockZone]) -> String {
377        stamp(TimeMs::new(at), zones).unwrap().render()
378    }
379
380    #[test]
381    fn a_stamp_reads_as_iso_utc_to_the_second() {
382        assert_eq!(rendered(0, &[]), "clock: 1970-01-01T00:00:00Z;");
383        assert_eq!(
384            rendered(1_709_251_199_999, &[]),
385            "clock: 2024-02-29T23:59:59Z;"
386        );
387        assert_eq!(
388            rendered(1_785_585_607_000, &[]),
389            "clock: 2026-08-01T12:00:07Z;"
390        );
391        assert_eq!(
392            rendered(978_287_400_000, &[]),
393            "clock: 2000-12-31T18:30:00Z;"
394        );
395    }
396
397    #[test]
398    fn a_zone_row_is_the_same_second_with_its_offset_across_midnight_both_ways() {
399        // 2026-08-01 12:00:07 UTC at +540 is 21:00:07 the same day; at
400        // -780 it crosses back to 23:00:07 of July 31.
401        let west = ClockZone {
402            id: "west".to_owned(),
403            offset_min: -780,
404        };
405        assert_eq!(
406            rendered(1_785_585_607_000, &[tokyo(), west]),
407            "clock: 2026-08-01T12:00:07Z; tokyo 2026-08-01T21:00:07+09:00; \
408             west 2026-07-31T23:00:07-13:00;"
409        );
410        assert!(stamp(TimeMs::new(0), &[]).unwrap().zones.is_empty());
411    }
412
413    #[test]
414    fn zones_beyond_the_cap_are_refused_not_dropped() {
415        let too_many: Vec<ClockZone> = (0..5)
416            .map(|i| ClockZone {
417                id: format!("z{i}"),
418                offset_min: 0,
419            })
420            .collect();
421        let err = stamp(TimeMs::new(0), &too_many).unwrap_err();
422        // The refusal is the limit's own, whole: a caller that spelled a
423        // sentence of its own would be a second authority for one limit.
424        assert_eq!(err, CLOCK_ZONES_MAX.admit(5).unwrap_err());
425    }
426
427    #[test]
428    fn off_emits_never_even_for_timestamped_tools() {
429        let mut gate = StampGate::new(ClockStampGranularity::Off, Vec::new());
430        for t in [1u64, 60_001, 3_600_001] {
431            let out = gate.observe(TimeMs::new(t), Temporal::Timestamped).unwrap();
432            assert!(out.is_none(), "A18: Off must cost zero bytes");
433        }
434    }
435
436    #[test]
437    fn first_result_emits_once_then_timeless_deduplicates_within_a_bucket() {
438        let mut gate = StampGate::new(ClockStampGranularity::Minute, Vec::new());
439        // First result of the run always stamps (even Timeless).
440        assert!(
441            gate.observe(TimeMs::new(1_000), Temporal::Timeless)
442                .unwrap()
443                .is_some()
444        );
445        // Same minute bucket: no stamp.
446        assert!(
447            gate.observe(TimeMs::new(30_000), Temporal::Timeless)
448                .unwrap()
449                .is_none()
450        );
451        // Next minute: stamps again, with the reading itself - the
452        // bucket decides how often, never what the stamp says.
453        let s = gate
454            .observe(TimeMs::new(61_000), Temporal::Timeless)
455            .unwrap()
456            .unwrap();
457        assert_eq!(s.utc_ms, TimeMs::new(61_000));
458    }
459
460    /// Every second from 1970 to 9999 is written by `iso` and read back
461    /// by `parse_iso` to the same second; the milliseconds `iso` drops are
462    /// the only difference.
463    #[test]
464    fn an_iso_moment_reads_back_to_the_second_it_was_written() {
465        let last = 253_402_300_799_999u64; // 9999-12-31T23:59:59.999Z
466        let step = 7_919_999_999u64; // not a whole day, so the sweep walks every hour
467        let moments = (0..=last / step).map(|k| k * step).chain([
468            0,
469            999,
470            951_782_400_000,   // 2000-02-29T00:00:00Z
471            1_709_251_199_999, // 2024-02-29T23:59:59.999Z
472            4_107_542_400_000, // 2100-03-01T00:00:00Z
473            last,
474        ]);
475        for at in moments {
476            let written = iso(TimeMs::new(at));
477            assert_eq!(
478                parse_iso(&written),
479                Ok(TimeMs::new(at - at % 1_000)),
480                "{written}"
481            );
482        }
483        assert_eq!(
484            parse_iso("2024-02-29T23:59:59Z"),
485            Ok(TimeMs::new(1_709_251_199_000))
486        );
487    }
488
489    /// Every spelling but the one `iso` writes is refused, and the refusal
490    /// shows the shape it wanted.
491    #[test]
492    fn parse_iso_refuses_every_spelling_iso_does_not_write() {
493        for raw in [
494            "2026-05-14T18:31:07+09:00",
495            "2026-05-14T09:31:07.000Z",
496            "2026-05-14",
497            "2026-05-14 09:31:07Z",
498            "2026-05-14t09:31:07z",
499            "2026-02-30T00:00:00Z",
500            "2025-02-29T00:00:00Z",
501            "2100-02-29T00:00:00Z",
502            "2026-13-01T00:00:00Z",
503            "2026-00-10T00:00:00Z",
504            "2026-05-00T00:00:00Z",
505            "2026-05-14T24:00:00Z",
506            "2026-05-14T09:60:00Z",
507            "2026-12-31T23:59:60Z",
508            "1969-12-31T23:59:59Z",
509            "+026-05-14T09:31:07Z",
510            " 2026-05-14T09:31:07Z",
511            "",
512        ] {
513            let refused = parse_iso(raw);
514            assert!(
515                matches!(&refused, Err(err) if *err.code() == AxCode::InvalidArgs
516                    && err.subject() == raw
517                    && err.recovery().contains("2026-05-14T09:31:07Z")),
518                "{raw:?}: {refused:?}"
519            );
520        }
521    }
522
523    /// `[since, until)`: the start is kept, the end is not, and an absent
524    /// end bounds nothing.
525    #[test]
526    fn a_span_keeps_its_start_and_leaves_out_its_end() {
527        let at = TimeMs::new;
528        let both = UtcSpan::new(Some(at(1_000)), Some(at(2_000))).unwrap();
529        assert_eq!(
530            [999, 1_000, 1_999, 2_000].map(|t| both.contains(at(t))),
531            [false, true, true, false]
532        );
533        let until = UtcSpan::new(None, Some(at(2_000))).unwrap();
534        assert_eq!([0, 2_000].map(|t| until.contains(at(t))), [true, false]);
535        let since = UtcSpan::new(Some(at(1_000)), None).unwrap();
536        assert_eq!(
537            [999, u64::MAX].map(|t| since.contains(at(t))),
538            [false, true]
539        );
540        assert!(UtcSpan::default().contains(at(0)));
541    }
542
543    /// A span that holds no moment is refused when it is built, naming
544    /// both ends.
545    #[test]
546    fn a_span_with_no_moment_in_it_is_refused() {
547        let at = TimeMs::new;
548        for (since, until) in [(2_000, 2_000), (2_001, 2_000)] {
549            let refused = UtcSpan::new(Some(at(since)), Some(at(until)));
550            assert!(
551                matches!(&refused, Err(err) if *err.code() == AxCode::InvalidArgs
552                    && err.subject().contains(&iso(at(since)))
553                    && err.subject().contains(&iso(at(until)))),
554                "{since}..{until}: {refused:?}"
555            );
556        }
557    }
558
559    #[test]
560    fn timestamped_emits_every_result_with_its_own_reading() {
561        let mut gate = StampGate::new(ClockStampGranularity::Hour, Vec::new());
562        let a = gate
563            .observe(TimeMs::new(1_000), Temporal::Timestamped)
564            .unwrap()
565            .unwrap();
566        let b = gate
567            .observe(TimeMs::new(2_000), Temporal::Timestamped)
568            .unwrap()
569            .unwrap();
570        assert_eq!(a.render(), "clock: 1970-01-01T00:00:01Z;");
571        assert_eq!(b.render(), "clock: 1970-01-01T00:00:02Z;");
572    }
573}