Skip to main content

uni_btic/
btic.rs

1use crate::certainty::Certainty;
2use crate::error::BticError;
3use crate::granularity::Granularity;
4use serde::{Deserialize, Serialize};
5use std::cmp::Ordering;
6use std::fmt;
7
8/// Sentinel value representing negative infinity (lower bound unbounded).
9pub const NEG_INF: i64 = i64::MIN;
10/// Sentinel value representing positive infinity (upper bound unbounded).
11pub const POS_INF: i64 = i64::MAX;
12/// XOR mask to flip sign bit for unsigned/memcmp-compatible ordering.
13pub const SIGN_FLIP: u64 = 0x8000_0000_0000_0000;
14
15/// A Binary Temporal Interval Codec value.
16///
17/// Represents a half-open temporal interval `[lo, hi)` in milliseconds since
18/// the Unix epoch (1970-01-01T00:00:00.000Z), with per-bound granularity and
19/// certainty metadata packed into a 64-bit meta word.
20///
21/// The 24-byte packed canonical form supports `memcmp`-based ordering for
22/// B-tree and LSM key storage.
23#[derive(Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
24pub struct Btic {
25    lo: i64,
26    hi: i64,
27    meta: u64,
28}
29
30impl Btic {
31    /// Construct a new BTIC value, validating all invariants (INV-1 through INV-7).
32    pub fn new(lo: i64, hi: i64, meta: u64) -> Result<Self, BticError> {
33        let btic = Self { lo, hi, meta };
34        btic.validate()?;
35        Ok(btic)
36    }
37
38    /// Validate all BTIC invariants on this value.
39    pub fn validate(&self) -> Result<(), BticError> {
40        // INV-1: lo < hi
41        if self.lo >= self.hi {
42            return Err(BticError::BoundOrdering {
43                lo: self.lo,
44                hi: self.hi,
45            });
46        }
47
48        // INV-5: granularity codes in range
49        let lo_gran_code = ((self.meta >> 60) & 0xF) as u8;
50        let hi_gran_code = ((self.meta >> 56) & 0xF) as u8;
51        if lo_gran_code > 0xA {
52            return Err(BticError::GranularityRange(lo_gran_code));
53        }
54        if hi_gran_code > 0xA {
55            return Err(BticError::GranularityRange(hi_gran_code));
56        }
57
58        // INV-4: version=0, flags=0, reserved=0
59        let version = ((self.meta >> 48) & 0xF) as u8;
60        let flags = ((self.meta >> 32) & 0xFFFF) as u16;
61        let reserved = (self.meta & 0xFFFF_FFFF) as u32;
62        if version != 0 || flags != 0 || reserved != 0 {
63            return Err(BticError::ReservedBits);
64        }
65
66        // INV-6: sentinel bounds must have zeroed granularity and certainty
67        if self.lo == NEG_INF {
68            let lo_cert_code = ((self.meta >> 54) & 0x3) as u8;
69            if lo_gran_code != 0 || lo_cert_code != 0 {
70                return Err(BticError::SentinelMetadata);
71            }
72        }
73        if self.hi == POS_INF {
74            let hi_cert_code = ((self.meta >> 52) & 0x3) as u8;
75            if hi_gran_code != 0 || hi_cert_code != 0 {
76                return Err(BticError::SentinelMetadata);
77            }
78        }
79
80        // INV-2 (sentinel exclusivity) is implied by INV-1 + INV-6:
81        // INV-1 prevents lo=POS_INF and hi=NEG_INF; INV-6 ensures sentinel metadata is zeroed.
82
83        Ok(())
84    }
85
86    // -----------------------------------------------------------------------
87    // Meta word construction
88    // -----------------------------------------------------------------------
89
90    /// Build a meta word from granularity and certainty values.
91    pub fn build_meta(
92        lo_gran: Granularity,
93        hi_gran: Granularity,
94        lo_cert: Certainty,
95        hi_cert: Certainty,
96    ) -> u64 {
97        ((lo_gran.code() as u64) << 60)
98            | ((hi_gran.code() as u64) << 56)
99            | ((lo_cert.code() as u64) << 54)
100            | ((hi_cert.code() as u64) << 52)
101        // version=0, flags=0, reserved=0 → remaining bits are 0
102    }
103
104    // -----------------------------------------------------------------------
105    // Meta word extraction
106    // -----------------------------------------------------------------------
107
108    /// Lower bound granularity.
109    pub fn lo_granularity(&self) -> Granularity {
110        Granularity::from_code(((self.meta >> 60) & 0xF) as u8).expect("validated on construction")
111    }
112
113    /// Upper bound granularity.
114    pub fn hi_granularity(&self) -> Granularity {
115        Granularity::from_code(((self.meta >> 56) & 0xF) as u8).expect("validated on construction")
116    }
117
118    /// Lower bound certainty.
119    pub fn lo_certainty(&self) -> Certainty {
120        Certainty::from_code(((self.meta >> 54) & 0x3) as u8).expect("validated on construction")
121    }
122
123    /// Upper bound certainty.
124    pub fn hi_certainty(&self) -> Certainty {
125        Certainty::from_code(((self.meta >> 52) & 0x3) as u8).expect("validated on construction")
126    }
127
128    // -----------------------------------------------------------------------
129    // Field accessors
130    // -----------------------------------------------------------------------
131
132    /// Lower bound in milliseconds since epoch. `i64::MIN` means -infinity.
133    pub fn lo(&self) -> i64 {
134        self.lo
135    }
136
137    /// Upper bound in milliseconds since epoch. `i64::MAX` means +infinity.
138    pub fn hi(&self) -> i64 {
139        self.hi
140    }
141
142    /// Raw meta word.
143    pub fn meta(&self) -> u64 {
144        self.meta
145    }
146
147    /// Duration in milliseconds. `None` if either bound is infinite.
148    pub fn duration_ms(&self) -> Option<i64> {
149        if self.lo == NEG_INF || self.hi == POS_INF {
150            None
151        } else {
152            Some(self.hi - self.lo)
153        }
154    }
155
156    /// True if this is an instant (one millisecond wide).
157    pub fn is_instant(&self) -> bool {
158        self.hi == self.lo + 1
159    }
160
161    /// True if either bound is infinite.
162    pub fn is_unbounded(&self) -> bool {
163        self.lo == NEG_INF || self.hi == POS_INF
164    }
165
166    /// True if both bounds are finite.
167    pub fn is_finite(&self) -> bool {
168        !self.is_unbounded()
169    }
170}
171
172// ---------------------------------------------------------------------------
173// Ordering: (lo, hi, meta) lexicographic — matches memcmp on packed form
174// ---------------------------------------------------------------------------
175
176impl PartialOrd for Btic {
177    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
178        Some(self.cmp(other))
179    }
180}
181
182impl Ord for Btic {
183    fn cmp(&self, other: &Self) -> Ordering {
184        self.lo
185            .cmp(&other.lo)
186            .then(self.hi.cmp(&other.hi))
187            .then(self.meta.cmp(&other.meta))
188    }
189}
190
191// ---------------------------------------------------------------------------
192// Display
193// ---------------------------------------------------------------------------
194
195impl fmt::Display for Btic {
196    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
197        let lo_str = if self.lo == NEG_INF {
198            "-inf".to_string()
199        } else {
200            format_ms_as_datetime(self.lo)
201        };
202        let hi_str = if self.hi == POS_INF {
203            "+inf".to_string()
204        } else {
205            format_ms_as_datetime(self.hi)
206        };
207
208        write!(f, "[{lo_str}, {hi_str})")?;
209
210        // Append granularity info
211        if self.lo != NEG_INF && self.hi != POS_INF {
212            let lg = self.lo_granularity();
213            let hg = self.hi_granularity();
214            if lg == hg {
215                write!(f, " ~{}", lg.name())?;
216            } else {
217                write!(f, " {}/{}", lg.name(), hg.name())?;
218            }
219        } else if self.lo != NEG_INF {
220            write!(f, " {}/", self.lo_granularity().name())?;
221        } else if self.hi != POS_INF {
222            write!(f, " /{}", self.hi_granularity().name())?;
223        }
224
225        // Append certainty if non-definite
226        let lc = self.lo_certainty();
227        let hc = self.hi_certainty();
228        if lc != Certainty::Definite || hc != Certainty::Definite {
229            if lc == hc {
230                write!(f, " [{}]", lc.name())?;
231            } else {
232                write!(f, " [{}/{}]", lc.name(), hc.name())?;
233            }
234        }
235
236        Ok(())
237    }
238}
239
240impl fmt::Debug for Btic {
241    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
242        f.debug_struct("Btic")
243            .field("lo", &self.lo)
244            .field("hi", &self.hi)
245            .field("meta", &format_args!("{:#018x}", self.meta))
246            .finish()
247    }
248}
249
250// ---------------------------------------------------------------------------
251// Helpers
252// ---------------------------------------------------------------------------
253
254/// Format milliseconds since epoch as an ISO 8601 datetime string.
255fn format_ms_as_datetime(ms: i64) -> String {
256    use chrono::{DateTime, Utc};
257
258    let secs = ms.div_euclid(1000);
259    let nanos = (ms.rem_euclid(1000) * 1_000_000) as u32;
260    match DateTime::<Utc>::from_timestamp(secs, nanos) {
261        Some(dt) => dt.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(),
262        None => format!("{ms}ms"),
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    #[test]
271    fn valid_instant_at_epoch() {
272        let meta = Btic::build_meta(
273            Granularity::Millisecond,
274            Granularity::Millisecond,
275            Certainty::Definite,
276            Certainty::Definite,
277        );
278        let b = Btic::new(0, 1, meta).unwrap();
279        assert!(b.is_instant());
280        assert!(b.is_finite());
281        assert!(!b.is_unbounded());
282        assert_eq!(b.duration_ms(), Some(1));
283    }
284
285    #[test]
286    fn inv1_lo_ge_hi_rejected() {
287        let meta = Btic::build_meta(
288            Granularity::Day,
289            Granularity::Day,
290            Certainty::Definite,
291            Certainty::Definite,
292        );
293        assert!(Btic::new(100, 100, meta).is_err());
294        assert!(Btic::new(200, 100, meta).is_err());
295    }
296
297    #[test]
298    fn inv5_bad_granularity_rejected() {
299        // Manually set granularity code 0xF in the meta word
300        let bad_meta = 0xF700_0000_0000_0000u64; // lo_gran=0xF
301        assert!(Btic::new(0, 1, bad_meta).is_err());
302    }
303
304    #[test]
305    fn inv6_sentinel_metadata_must_be_zero() {
306        // NEG_INF lo with non-zero lo_granularity
307        let bad_meta = Btic::build_meta(
308            Granularity::Year,
309            Granularity::Month,
310            Certainty::Definite,
311            Certainty::Definite,
312        );
313        assert!(Btic::new(NEG_INF, 1000, bad_meta).is_err());
314    }
315
316    #[test]
317    fn unbounded_intervals() {
318        let meta_lo_zero = Btic::build_meta(
319            Granularity::Millisecond,
320            Granularity::Month,
321            Certainty::Definite,
322            Certainty::Definite,
323        );
324        let left_unbounded = Btic::new(NEG_INF, 1000, meta_lo_zero).unwrap();
325        assert!(left_unbounded.is_unbounded());
326        assert!(!left_unbounded.is_finite());
327        assert_eq!(left_unbounded.duration_ms(), None);
328
329        let meta_hi_zero = Btic::build_meta(
330            Granularity::Month,
331            Granularity::Millisecond,
332            Certainty::Definite,
333            Certainty::Definite,
334        );
335        let right_unbounded = Btic::new(1000, POS_INF, meta_hi_zero).unwrap();
336        assert!(right_unbounded.is_unbounded());
337        assert_eq!(right_unbounded.duration_ms(), None);
338    }
339
340    #[test]
341    fn ordering_lo_first() {
342        let meta = Btic::build_meta(
343            Granularity::Day,
344            Granularity::Day,
345            Certainty::Definite,
346            Certainty::Definite,
347        );
348        let a = Btic::new(100, 200, meta).unwrap();
349        let b = Btic::new(150, 200, meta).unwrap();
350        assert!(a < b);
351    }
352
353    #[test]
354    fn ordering_hi_second() {
355        let meta = Btic::build_meta(
356            Granularity::Day,
357            Granularity::Day,
358            Certainty::Definite,
359            Certainty::Definite,
360        );
361        let a = Btic::new(100, 200, meta).unwrap();
362        let b = Btic::new(100, 300, meta).unwrap();
363        assert!(a < b);
364    }
365
366    #[test]
367    fn ordering_meta_third() {
368        let meta_a = Btic::build_meta(
369            Granularity::Day,
370            Granularity::Day,
371            Certainty::Definite,
372            Certainty::Definite,
373        );
374        let meta_b = Btic::build_meta(
375            Granularity::Year,
376            Granularity::Year,
377            Certainty::Definite,
378            Certainty::Definite,
379        );
380        let a = Btic::new(100, 200, meta_a).unwrap();
381        let b = Btic::new(100, 200, meta_b).unwrap();
382        assert!(a < b); // Day(0x4) < Year(0x7) in meta
383    }
384
385    #[test]
386    fn display_finite_interval() {
387        let meta = Btic::build_meta(
388            Granularity::Year,
389            Granularity::Year,
390            Certainty::Definite,
391            Certainty::Definite,
392        );
393        // 1985-01-01 to 1986-01-01
394        let b = Btic::new(473_385_600_000, 504_921_600_000, meta).unwrap();
395        let s = b.to_string();
396        assert!(s.contains("1985-01-01"));
397        assert!(s.contains("1986-01-01"));
398        assert!(s.contains("~year"));
399    }
400
401    #[test]
402    fn build_meta_roundtrip() {
403        let meta = Btic::build_meta(
404            Granularity::Month,
405            Granularity::Day,
406            Certainty::Approximate,
407            Certainty::Uncertain,
408        );
409        let b = Btic::new(0, 1000, meta).unwrap();
410        assert_eq!(b.lo_granularity(), Granularity::Month);
411        assert_eq!(b.hi_granularity(), Granularity::Day);
412        assert_eq!(b.lo_certainty(), Certainty::Approximate);
413        assert_eq!(b.hi_certainty(), Certainty::Uncertain);
414    }
415}