Skip to main content

stt_core/
timestamp.rs

1//! Shared timestamp-unit normalization for all STT input adaptors.
2//!
3//! Every input reader — the GeoParquet scalar (`--time-field`) and per-vertex
4//! (`vertex_timestamps`) paths in `stt-build`, the DuckDB reader, and the
5//! `stt-optimize` analysis loader — must scale a producer's raw integer
6//! timestamp to the STT wire unit (Unix **milliseconds**) identically. This is
7//! the one source of truth for that arithmetic so a new reader can't silently
8//! disagree.
9
10use crate::error::{Error, Result};
11
12/// Precision of a raw integer timestamp value. Used to normalize any producer's
13/// unit to the STT wire unit (Unix **milliseconds**).
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum TimestampUnit {
16    /// Seconds since the Unix epoch (×1000 → ms).
17    Second,
18    /// Milliseconds since the Unix epoch (passthrough — the wire unit).
19    Millisecond,
20    /// Microseconds since the Unix epoch (÷1_000 → ms).
21    Microsecond,
22    /// Nanoseconds since the Unix epoch (÷1_000_000 → ms).
23    Nanosecond,
24}
25
26/// Scale a raw timestamp `value` in `unit` to **signed** Unix milliseconds
27/// WITHOUT the non-negative guard. Second→ms is overflow-CHECKED (any realistic
28/// date fits; a value that doesn't is corrupt input and must error, never
29/// silently saturate). This is the single source of truth for timestamp-unit
30/// scaling arithmetic — `Second` ×1000, `Millisecond` passthrough, `Microsecond`
31/// ÷1_000, `Nanosecond` ÷1_000_000 — shared by [`normalize_timestamp_to_ms`]
32/// (which adds the guard + `u64` cast) and the DuckDB reader's
33/// `timestamp_unit_to_ms` (which keeps signed ms so a timestamp emitted as a
34/// plain *property* may legitimately be pre-1970). Callers that need a wire
35/// timestamp apply [`reject_negative_timestamp`] themselves.
36pub fn scale_timestamp_to_ms(value: i64, unit: TimestampUnit) -> Result<i64> {
37    match unit {
38        TimestampUnit::Second => value.checked_mul(1_000).ok_or_else(|| {
39            Error::Other(format!(
40                "second-precision timestamp {value} overflows the millisecond range"
41            ))
42        }),
43        TimestampUnit::Millisecond => Ok(value),
44        TimestampUnit::Microsecond => Ok(value / 1_000),
45        TimestampUnit::Nanosecond => Ok(value / 1_000_000),
46    }
47}
48
49/// Pre-1970 timestamps cannot be represented — the temporal index stores
50/// unsigned ms-since-epoch, so a negative value would wrap to a huge
51/// positive one (`as u64`) and silently corrupt the index. This hard-errors
52/// regardless of strictness mode; coercion is never sound here.
53pub fn reject_negative_timestamp(row: usize, value: i64) -> Result<()> {
54    if value < 0 {
55        return Err(Error::Other(format!(
56            "row {row}: negative timestamp {value} (pre-1970). The STT temporal \
57             index stores unsigned ms-since-epoch and cannot represent pre-1970 \
58             times; filter or re-epoch these rows before building."
59        )));
60    }
61    Ok(())
62}
63
64/// Normalize a raw integer timestamp `value` in `unit` to Unix **milliseconds**,
65/// rejecting pre-1970 (negative) instants and overflow-checking the
66/// second→millisecond multiply. `row` is the absolute row index, used only for
67/// error context.
68///
69/// The single shared helper the GeoParquet scalar (`--time-field`) and
70/// per-vertex (`vertex_timestamps`) readers call; the DuckDB reader agrees on
71/// the scaling via [`scale_timestamp_to_ms`]. See [`TimestampUnit`].
72pub fn normalize_timestamp_to_ms(row: usize, value: i64, unit: TimestampUnit) -> Result<u64> {
73    reject_negative_timestamp(row, value)?;
74    // Only Second can overflow (ms passthrough / µs ÷1e3 / ns ÷1e6 shrink or
75    // preserve); re-wrap the shared error with row context.
76    let ms = scale_timestamp_to_ms(value, unit).map_err(|_| {
77        Error::Other(format!(
78            "row {row}: second-precision timestamp {value} overflows the millisecond range"
79        ))
80    })?;
81    Ok(ms as u64)
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    #[test]
89    fn scale_matches_units() {
90        assert_eq!(scale_timestamp_to_ms(5, TimestampUnit::Second).unwrap(), 5000);
91        assert_eq!(scale_timestamp_to_ms(5, TimestampUnit::Millisecond).unwrap(), 5);
92        assert_eq!(scale_timestamp_to_ms(5_000, TimestampUnit::Microsecond).unwrap(), 5);
93        assert_eq!(scale_timestamp_to_ms(5_000_000, TimestampUnit::Nanosecond).unwrap(), 5);
94    }
95
96    #[test]
97    fn scale_second_overflow_errors() {
98        assert!(scale_timestamp_to_ms(i64::MAX, TimestampUnit::Second).is_err());
99        assert!(scale_timestamp_to_ms(i64::MIN, TimestampUnit::Second).is_err());
100    }
101
102    #[test]
103    fn normalize_rejects_negative() {
104        assert!(normalize_timestamp_to_ms(0, -1, TimestampUnit::Millisecond).is_err());
105        assert_eq!(
106            normalize_timestamp_to_ms(0, 1_000, TimestampUnit::Second).unwrap(),
107            1_000_000
108        );
109    }
110
111    #[test]
112    fn normalize_second_overflow_errors() {
113        assert!(normalize_timestamp_to_ms(0, i64::MAX, TimestampUnit::Second).is_err());
114    }
115}