Skip to main content

timeseries_table_format/coverage/
index_interval.rs

1//! Stable order-preserving mappings from ordered-index values to index interval IDs.
2
3use std::{fmt, ops::RangeInclusive};
4
5use chrono::{DateTime, Duration, SecondsFormat, TimeZone, Utc};
6use snafu::Snafu;
7
8use crate::{
9    coverage::IndexIntervalId,
10    metadata::index::{
11        IndexKind, IndexValue, IndexValueError, TimeIndexGranularity, validate_index_range,
12    },
13};
14
15const SIGN_BIT: u64 = 0x8000_0000_0000_0000;
16const SECONDS_PER_MINUTE: u64 = 60;
17const SECONDS_PER_HOUR: u64 = 60 * 60;
18const SECONDS_PER_DAY: u64 = 24 * 60 * 60;
19
20/// Errors produced while mapping ordered values to index interval IDs.
21#[derive(Debug, Snafu, PartialEq, Eq)]
22#[non_exhaustive]
23pub enum IndexIntervalMappingError {
24    /// The value or range does not match the registered index domain.
25    #[snafu(display("Invalid ordered index value: {source}"))]
26    IndexValue {
27        /// Domain or range validation error.
28        source: IndexValueError,
29    },
30    /// A directly constructed timestamp index granularity has a zero width.
31    #[snafu(display("Timestamp index granularity must be nonzero"))]
32    ZeroTimeIndexGranularity,
33    /// A validated range end could not be adjusted to the final included value.
34    #[snafu(display("Ordered range end cannot be adjusted to its predecessor: {end}"))]
35    RangeEndUnderflow {
36        /// Exclusive range end.
37        end: IndexValue,
38    },
39    /// An index interval ID cannot occur in the configured logical index domain.
40    #[snafu(display(
41        "Index interval ID {index_interval_id} is outside the logical {kind} index domain"
42    ))]
43    IntervalIdOutsideDomain {
44        /// Registered ordered-index domain.
45        kind: &'static str,
46        /// Internal index interval ID.
47        index_interval_id: IndexIntervalId,
48    },
49}
50
51/// Logical ordered-index interval represented by one index interval ID.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct IndexInterval {
54    start: IndexValue,
55    end: IndexValue,
56    end_inclusive: bool,
57}
58
59impl IndexInterval {
60    fn new(start: IndexValue, end: IndexValue, end_inclusive: bool) -> Self {
61        Self {
62            start,
63            end,
64            end_inclusive,
65        }
66    }
67
68    /// Logical start value, always included.
69    pub fn start(&self) -> &IndexValue {
70        &self.start
71    }
72
73    /// Logical end value.
74    pub fn end(&self) -> &IndexValue {
75        &self.end
76    }
77
78    /// Whether the end is included because the interval reaches the domain maximum.
79    pub fn end_inclusive(&self) -> bool {
80        self.end_inclusive
81    }
82}
83
84impl fmt::Display for IndexInterval {
85    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86        let close = if self.end_inclusive { ']' } else { ')' };
87        match (&self.start, &self.end) {
88            (IndexValue::Timestamp(start), IndexValue::Timestamp(end)) => write!(
89                f,
90                "[{}, {}{close}",
91                start.to_rfc3339_opts(SecondsFormat::AutoSi, true),
92                end.to_rfc3339_opts(SecondsFormat::AutoSi, true)
93            ),
94            (IndexValue::Int64(start), IndexValue::Int64(end)) => {
95                write!(f, "[{start}, {end}{close}")
96            }
97            (IndexValue::UInt64(start), IndexValue::UInt64(end)) => {
98                write!(f, "[{start}, {end}{close}")
99            }
100            _ => unreachable!("index interval endpoints share one index domain"),
101        }
102    }
103}
104
105fn time_index_granularity_seconds(
106    index_granularity: &TimeIndexGranularity,
107) -> Result<u64, IndexIntervalMappingError> {
108    let (value, multiplier) = match *index_granularity {
109        TimeIndexGranularity::Seconds(value) => (value, 1),
110        TimeIndexGranularity::Minutes(value) => (value, SECONDS_PER_MINUTE),
111        TimeIndexGranularity::Hours(value) => (value, SECONDS_PER_HOUR),
112        TimeIndexGranularity::Days(value) => (value, SECONDS_PER_DAY),
113    };
114    if value == 0 {
115        return Err(IndexIntervalMappingError::ZeroTimeIndexGranularity);
116    }
117    Ok(u64::from(value) * multiplier)
118}
119
120fn signed_index_interval_id(ordinal: i64) -> IndexIntervalId {
121    (ordinal as u64) ^ SIGN_BIT
122}
123
124/// Map seconds since the Unix epoch to a timestamp index interval ID.
125///
126/// This lower-level helper is shared by the timestamp Parquet coverage path.
127pub fn index_interval_id_from_epoch_secs(
128    index_granularity: &TimeIndexGranularity,
129    seconds: i64,
130) -> Result<IndexIntervalId, IndexIntervalMappingError> {
131    let width = i128::from(time_index_granularity_seconds(index_granularity)?);
132    let ordinal = i128::from(seconds).div_euclid(width) as i64;
133    Ok(signed_index_interval_id(ordinal))
134}
135
136fn timestamp_index_interval_id(
137    index_granularity: &TimeIndexGranularity,
138    value: DateTime<Utc>,
139) -> Result<IndexIntervalId, IndexIntervalMappingError> {
140    index_interval_id_from_epoch_secs(index_granularity, value.timestamp())
141}
142
143fn int64_index_interval_id(value: i64, index_granularity: u64) -> IndexIntervalId {
144    let ordinal = i128::from(value).div_euclid(i128::from(index_granularity)) as i64;
145    signed_index_interval_id(ordinal)
146}
147
148/// Map an ordered-index value to its canonical index interval ID.
149pub fn index_interval_id_for_value(
150    kind: &IndexKind,
151    value: &IndexValue,
152) -> Result<IndexIntervalId, IndexIntervalMappingError> {
153    value
154        .validate_kind(kind)
155        .map_err(|source| IndexIntervalMappingError::IndexValue { source })?;
156
157    match (kind, value) {
158        (
159            IndexKind::Timestamp {
160                index_granularity, ..
161            },
162            IndexValue::Timestamp(value),
163        ) => timestamp_index_interval_id(index_granularity, *value),
164        (IndexKind::Int64 { index_granularity }, IndexValue::Int64(value)) => {
165            Ok(int64_index_interval_id(*value, index_granularity.get()))
166        }
167        (IndexKind::UInt64 { index_granularity }, IndexValue::UInt64(value)) => {
168            Ok(*value / index_granularity.get())
169        }
170        _ => unreachable!("value domain was validated above"),
171    }
172}
173
174/// Decode one index interval ID into its logical ordered-index interval.
175pub fn index_interval_for_id(
176    kind: &IndexKind,
177    index_interval_id: IndexIntervalId,
178) -> Result<IndexInterval, IndexIntervalMappingError> {
179    let outside_domain = || IndexIntervalMappingError::IntervalIdOutsideDomain {
180        kind: kind.name(),
181        index_interval_id,
182    };
183
184    match kind {
185        IndexKind::Timestamp {
186            index_granularity, ..
187        } => {
188            let ordinal = i128::from((index_interval_id ^ SIGN_BIT) as i64);
189            let width = i128::from(time_index_granularity_seconds(index_granularity)?);
190            let domain_start = i128::from(DateTime::<Utc>::MIN_UTC.timestamp());
191            let domain_end = i128::from(DateTime::<Utc>::MAX_UTC.timestamp()) + 1;
192            let start = (ordinal * width).max(domain_start);
193            let end = ((ordinal + 1) * width).min(domain_end);
194            if start >= end {
195                return Err(outside_domain());
196            }
197
198            let start = Utc
199                .timestamp_opt(start as i64, 0)
200                .single()
201                .ok_or_else(&outside_domain)?;
202            let end_inclusive = end == domain_end;
203            let end = if end_inclusive {
204                DateTime::<Utc>::MAX_UTC
205            } else {
206                Utc.timestamp_opt(end as i64, 0)
207                    .single()
208                    .ok_or_else(&outside_domain)?
209            };
210            Ok(IndexInterval::new(start.into(), end.into(), end_inclusive))
211        }
212        IndexKind::Int64 { index_granularity } => {
213            let ordinal = i128::from((index_interval_id ^ SIGN_BIT) as i64);
214            let width = i128::from(index_granularity.get());
215            let domain_start = i128::from(i64::MIN);
216            let domain_end = i128::from(i64::MAX) + 1;
217            let start = (ordinal * width).max(domain_start);
218            let end = ((ordinal + 1) * width).min(domain_end);
219            if start >= end {
220                return Err(outside_domain());
221            }
222
223            let end_inclusive = end == domain_end;
224            Ok(IndexInterval::new(
225                IndexValue::Int64(start as i64),
226                IndexValue::Int64(if end_inclusive { i64::MAX } else { end as i64 }),
227                end_inclusive,
228            ))
229        }
230        IndexKind::UInt64 { index_granularity } => {
231            let width = u128::from(index_granularity.get());
232            let domain_end = u128::from(u64::MAX) + 1;
233            let start = u128::from(index_interval_id) * width;
234            let end = ((u128::from(index_interval_id) + 1) * width).min(domain_end);
235            if start >= end {
236                return Err(outside_domain());
237            }
238
239            let end_inclusive = end == domain_end;
240            Ok(IndexInterval::new(
241                IndexValue::UInt64(start as u64),
242                IndexValue::UInt64(if end_inclusive { u64::MAX } else { end as u64 }),
243                end_inclusive,
244            ))
245        }
246    }
247}
248
249/// Return the first and last index interval IDs intersecting `[start, end)`.
250pub fn index_interval_id_range(
251    kind: &IndexKind,
252    start: &IndexValue,
253    end: &IndexValue,
254) -> Result<RangeInclusive<IndexIntervalId>, IndexIntervalMappingError> {
255    validate_index_range(kind, start, end)
256        .map_err(|source| IndexIntervalMappingError::IndexValue { source })?;
257
258    let first = index_interval_id_for_value(kind, start)?;
259    Ok(first..=index_interval_id_for_value(kind, &value_before(end)?)?)
260}
261
262/// Return the index interval ID containing the value before an exclusive end.
263pub fn index_interval_id_for_exclusive_end(
264    kind: &IndexKind,
265    end: &IndexValue,
266) -> Result<IndexIntervalId, IndexIntervalMappingError> {
267    end.validate_kind(kind)
268        .map_err(|source| IndexIntervalMappingError::IndexValue { source })?;
269    index_interval_id_for_value(kind, &value_before(end)?)
270}
271
272fn value_before(end: &IndexValue) -> Result<IndexValue, IndexIntervalMappingError> {
273    Ok(match end {
274        IndexValue::Timestamp(end) => {
275            IndexValue::Timestamp(end.checked_sub_signed(Duration::nanoseconds(1)).ok_or(
276                IndexIntervalMappingError::RangeEndUnderflow {
277                    end: IndexValue::Timestamp(*end),
278                },
279            )?)
280        }
281        IndexValue::Int64(end) => IndexValue::Int64(end.checked_sub(1).ok_or({
282            IndexIntervalMappingError::RangeEndUnderflow {
283                end: IndexValue::Int64(*end),
284            }
285        })?),
286        IndexValue::UInt64(end) => IndexValue::UInt64(end.checked_sub(1).ok_or({
287            IndexIntervalMappingError::RangeEndUnderflow {
288                end: IndexValue::UInt64(*end),
289            }
290        })?),
291    })
292}
293
294#[cfg(test)]
295mod tests {
296    use std::num::NonZeroU64;
297
298    use chrono::TimeZone;
299
300    use super::*;
301
302    fn timestamp_kind(index_granularity: TimeIndexGranularity) -> IndexKind {
303        IndexKind::Timestamp {
304            index_granularity,
305            timezone: None,
306        }
307    }
308
309    #[test]
310    fn timestamp_mapping_is_ordered_across_epoch() {
311        let kind = timestamp_kind(TimeIndexGranularity::Seconds(1));
312        let before = Utc.timestamp_opt(-1, 0).single().unwrap().into();
313        let epoch = Utc.timestamp_opt(0, 0).single().unwrap().into();
314        let after = Utc.timestamp_opt(1, 0).single().unwrap().into();
315
316        assert_eq!(
317            index_interval_id_for_value(&kind, &before).unwrap(),
318            SIGN_BIT - 1
319        );
320        assert_eq!(
321            index_interval_id_for_value(&kind, &epoch).unwrap(),
322            SIGN_BIT
323        );
324        assert_eq!(
325            index_interval_id_for_value(&kind, &after).unwrap(),
326            SIGN_BIT + 1
327        );
328    }
329
330    #[test]
331    fn timestamp_mapping_uses_euclidean_intervals_before_epoch() {
332        let index_granularity = TimeIndexGranularity::Minutes(1);
333        assert_eq!(
334            index_interval_id_from_epoch_secs(&index_granularity, -61).unwrap(),
335            SIGN_BIT - 2
336        );
337        assert_eq!(
338            index_interval_id_from_epoch_secs(&index_granularity, -60).unwrap(),
339            SIGN_BIT - 1
340        );
341        assert_eq!(
342            index_interval_id_from_epoch_secs(&index_granularity, -1).unwrap(),
343            SIGN_BIT - 1
344        );
345        assert_eq!(
346            index_interval_id_from_epoch_secs(&index_granularity, 0).unwrap(),
347            SIGN_BIT
348        );
349    }
350
351    #[test]
352    fn int64_mapping_handles_zero_and_extremes() {
353        for width in [1, 3, u64::MAX] {
354            let kind = IndexKind::Int64 {
355                index_granularity: NonZeroU64::new(width).unwrap(),
356            };
357            let values = [i64::MIN, -1, 0, 1, i64::MAX];
358            let interval_ids: Vec<_> = values
359                .into_iter()
360                .map(|value| index_interval_id_for_value(&kind, &value.into()).unwrap())
361                .collect();
362            assert!(interval_ids.windows(2).all(|pair| pair[0] <= pair[1]));
363        }
364
365        let unit = IndexKind::Int64 {
366            index_granularity: NonZeroU64::new(1).unwrap(),
367        };
368        assert_eq!(
369            index_interval_id_for_value(&unit, &i64::MIN.into()).unwrap(),
370            0
371        );
372        assert_eq!(
373            index_interval_id_for_value(&unit, &0i64.into()).unwrap(),
374            SIGN_BIT
375        );
376        assert_eq!(
377            index_interval_id_for_value(&unit, &i64::MAX.into()).unwrap(),
378            u64::MAX
379        );
380    }
381
382    #[test]
383    fn uint64_mapping_is_exact_through_max() {
384        let unit_granularity_kind = IndexKind::UInt64 {
385            index_granularity: NonZeroU64::new(1).unwrap(),
386        };
387        for value in [0, i64::MAX as u64 + 1, u64::MAX] {
388            assert_eq!(
389                index_interval_id_for_value(&unit_granularity_kind, &value.into()).unwrap(),
390                value
391            );
392        }
393
394        let ten_value_granularity_kind = IndexKind::UInt64 {
395            index_granularity: NonZeroU64::new(10).unwrap(),
396        };
397        assert_eq!(
398            index_interval_id_for_value(&ten_value_granularity_kind, &u64::MAX.into()).unwrap(),
399            u64::MAX / 10
400        );
401    }
402
403    #[test]
404    fn index_intervals_use_configured_index_units() {
405        let signed_unit = IndexKind::Int64 {
406            index_granularity: NonZeroU64::new(1).unwrap(),
407        };
408        let signed_unit_interval_id =
409            index_interval_id_for_value(&signed_unit, &50_464i64.into()).unwrap();
410        assert_eq!(
411            index_interval_for_id(&signed_unit, signed_unit_interval_id)
412                .unwrap()
413                .to_string(),
414            "[50464, 50465)"
415        );
416
417        let signed = IndexKind::Int64 {
418            index_granularity: NonZeroU64::new(10).unwrap(),
419        };
420        let signed_interval_id = index_interval_id_for_value(&signed, &(-11i64).into()).unwrap();
421        assert_eq!(
422            index_interval_for_id(&signed, signed_interval_id)
423                .unwrap()
424                .to_string(),
425            "[-20, -10)"
426        );
427
428        let unsigned = IndexKind::UInt64 {
429            index_granularity: NonZeroU64::new(10).unwrap(),
430        };
431        let unsigned_interval_id =
432            index_interval_id_for_value(&unsigned, &50_464u64.into()).unwrap();
433        assert_eq!(
434            index_interval_for_id(&unsigned, unsigned_interval_id)
435                .unwrap()
436                .to_string(),
437            "[50460, 50470)"
438        );
439
440        let timestamp = timestamp_kind(TimeIndexGranularity::Hours(1));
441        let epoch = Utc.timestamp_opt(0, 0).single().unwrap();
442        let timestamp_interval_id = index_interval_id_for_value(&timestamp, &epoch.into()).unwrap();
443        assert_eq!(
444            index_interval_for_id(&timestamp, timestamp_interval_id)
445                .unwrap()
446                .to_string(),
447            "[1970-01-01T00:00:00Z, 1970-01-01T01:00:00Z)"
448        );
449
450        let before_epoch = Utc.timestamp_opt(-1, 0).single().unwrap();
451        let before_epoch_interval_id =
452            index_interval_id_for_value(&timestamp, &before_epoch.into()).unwrap();
453        assert_eq!(
454            index_interval_for_id(&timestamp, before_epoch_interval_id)
455                .unwrap()
456                .to_string(),
457            "[1969-12-31T23:00:00Z, 1970-01-01T00:00:00Z)"
458        );
459    }
460
461    #[test]
462    fn index_intervals_clip_at_domain_maximum() -> Result<(), IndexIntervalMappingError> {
463        let signed = IndexKind::Int64 {
464            index_granularity: NonZeroU64::new(10).unwrap(),
465        };
466        let signed_range = index_interval_for_id(
467            &signed,
468            index_interval_id_for_value(&signed, &i64::MAX.into()).unwrap(),
469        )?;
470        assert_eq!(signed_range.end(), &IndexValue::Int64(i64::MAX));
471        assert!(signed_range.end_inclusive());
472        let signed_min_range = index_interval_for_id(
473            &signed,
474            index_interval_id_for_value(&signed, &i64::MIN.into()).unwrap(),
475        )?;
476        assert_eq!(signed_min_range.start(), &IndexValue::Int64(i64::MIN));
477        assert!(!signed_min_range.end_inclusive());
478
479        let unsigned = IndexKind::UInt64 {
480            index_granularity: NonZeroU64::new(10).unwrap(),
481        };
482        let unsigned_range = index_interval_for_id(
483            &unsigned,
484            index_interval_id_for_value(&unsigned, &u64::MAX.into()).unwrap(),
485        )?;
486        assert_eq!(unsigned_range.end(), &IndexValue::UInt64(u64::MAX));
487        assert!(unsigned_range.end_inclusive());
488
489        let timestamp = timestamp_kind(TimeIndexGranularity::Days(u32::MAX));
490        let timestamp_range = index_interval_for_id(
491            &timestamp,
492            index_interval_id_for_value(
493                &timestamp,
494                &IndexValue::Timestamp(DateTime::<Utc>::MAX_UTC),
495            )?,
496        )?;
497        assert_eq!(
498            timestamp_range.end(),
499            &IndexValue::Timestamp(DateTime::<Utc>::MAX_UTC)
500        );
501        assert!(timestamp_range.end_inclusive());
502        let timestamp_min_range = index_interval_for_id(
503            &timestamp,
504            index_interval_id_for_value(
505                &timestamp,
506                &IndexValue::Timestamp(DateTime::<Utc>::MIN_UTC),
507            )?,
508        )?;
509        assert_eq!(
510            timestamp_min_range.start(),
511            &IndexValue::Timestamp(DateTime::<Utc>::MIN_UTC)
512        );
513
514        Ok(())
515    }
516
517    #[test]
518    fn index_interval_rejects_unreachable_interval_id() {
519        let kind = IndexKind::UInt64 {
520            index_granularity: NonZeroU64::new(2).unwrap(),
521        };
522        assert!(matches!(
523            index_interval_for_id(&kind, u64::MAX),
524            Err(IndexIntervalMappingError::IntervalIdOutsideDomain { .. })
525        ));
526    }
527
528    #[test]
529    fn half_open_integer_ranges_do_not_cross_end_boundary() {
530        let signed = IndexKind::Int64 {
531            index_granularity: NonZeroU64::new(10).unwrap(),
532        };
533        let range = index_interval_id_range(&signed, &0i64.into(), &20i64.into()).unwrap();
534        assert_eq!(range, SIGN_BIT..=SIGN_BIT + 1);
535        assert_eq!(
536            index_interval_id_for_exclusive_end(&signed, &20i64.into()).unwrap(),
537            SIGN_BIT + 1
538        );
539
540        let unsigned = IndexKind::UInt64 {
541            index_granularity: NonZeroU64::new(10).unwrap(),
542        };
543        assert_eq!(
544            index_interval_id_range(&unsigned, &0u64.into(), &20u64.into()).unwrap(),
545            0..=1
546        );
547        assert_eq!(
548            index_interval_id_range(&unsigned, &0u64.into(), &1u64.into()).unwrap(),
549            0..=0
550        );
551    }
552
553    #[test]
554    fn half_open_timestamp_range_preserves_nanoseconds() {
555        let kind = timestamp_kind(TimeIndexGranularity::Seconds(1));
556        let start = Utc.timestamp_opt(0, 0).single().unwrap();
557        let boundary = Utc.timestamp_opt(2, 0).single().unwrap();
558
559        assert_eq!(
560            index_interval_id_range(&kind, &start.into(), &boundary.into()).unwrap(),
561            SIGN_BIT..=SIGN_BIT + 1
562        );
563        assert_eq!(
564            index_interval_id_range(
565                &kind,
566                &start.into(),
567                &(boundary + Duration::nanoseconds(1)).into(),
568            )
569            .unwrap(),
570            SIGN_BIT..=SIGN_BIT + 2
571        );
572    }
573
574    #[test]
575    fn invalid_domains_ranges_and_zero_time_granularities_are_errors() {
576        let kind = timestamp_kind(TimeIndexGranularity::Seconds(0));
577        let epoch = Utc.timestamp_opt(0, 0).single().unwrap();
578        assert_eq!(
579            index_interval_id_for_value(&kind, &epoch.into()),
580            Err(IndexIntervalMappingError::ZeroTimeIndexGranularity)
581        );
582
583        let unsigned = IndexKind::UInt64 {
584            index_granularity: NonZeroU64::new(1).unwrap(),
585        };
586        assert!(matches!(
587            index_interval_id_range(&unsigned, &0i64.into(), &1i64.into()),
588            Err(IndexIntervalMappingError::IndexValue { .. })
589        ));
590        assert!(matches!(
591            index_interval_id_range(&unsigned, &1u64.into(), &1u64.into()),
592            Err(IndexIntervalMappingError::IndexValue { .. })
593        ));
594        assert!(matches!(
595            index_interval_id_for_exclusive_end(&unsigned, &0u64.into()),
596            Err(IndexIntervalMappingError::RangeEndUnderflow { .. })
597        ));
598    }
599}