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}