Skip to main content

subcog/models/
temporal.rs

1//! Bitemporal types for knowledge graph time tracking.
2//!
3//! This module provides types for implementing bitemporal data models,
4//! enabling queries like "what did we know at time T?" and "when was this fact true?"
5//!
6//! # Bitemporal Concepts
7//!
8//! Bitemporal data tracking uses two independent time dimensions:
9//!
10//! | Dimension | Question Answered | Example |
11//! |-----------|-------------------|---------|
12//! | **Valid Time** | When was this fact true in the real world? | "Alice worked at Acme from 2020-2023" |
13//! | **Transaction Time** | When was this fact recorded in the system? | "We learned this on 2024-01-15" |
14//!
15//! # Valid Time Semantics
16//!
17//! Valid time represents when a fact was/is/will be true in the real world:
18//!
19//! - `ValidTimeRange::unbounded()` - Always true (default for most entities)
20//! - `ValidTimeRange::from(start)` - True from `start` onwards
21//! - `ValidTimeRange::until(end)` - True until `end`
22//! - `ValidTimeRange::between(start, end)` - True during the interval
23//!
24//! # Transaction Time Semantics
25//!
26//! Transaction time is automatically set when a record is created and never modified.
27//! This enables auditing and point-in-time queries ("what did the system know at time T?").
28//!
29//! # Example
30//!
31//! ```rust
32//! use subcog::models::temporal::{ValidTimeRange, TransactionTime};
33//!
34//! // Entity was valid from Jan 1, 2024 onwards
35//! let valid_time = ValidTimeRange::from(1704067200);
36//!
37//! // Check if valid at a specific point
38//! assert!(valid_time.contains(1704153600)); // Jan 2, 2024
39//! assert!(!valid_time.contains(1703980800)); // Dec 31, 2023
40//!
41//! // Transaction time is auto-set to now
42//! let tx_time = TransactionTime::now();
43//! assert!(tx_time.timestamp() > 0);
44//! ```
45
46use serde::{Deserialize, Serialize};
47use std::fmt;
48use std::time::{SystemTime, UNIX_EPOCH};
49
50/// Represents when a fact was true in the real world (valid time).
51///
52/// This is a half-open interval `[start, end)` where:
53/// - `start` is inclusive (None means unbounded past)
54/// - `end` is exclusive (None means unbounded future)
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
56pub struct ValidTimeRange {
57    /// Start of validity (inclusive), None for unbounded past.
58    pub start: Option<i64>,
59    /// End of validity (exclusive), None for unbounded future.
60    pub end: Option<i64>,
61}
62
63impl ValidTimeRange {
64    /// Creates an unbounded time range (always valid).
65    #[must_use]
66    pub const fn unbounded() -> Self {
67        Self {
68            start: None,
69            end: None,
70        }
71    }
72
73    /// Creates a time range starting from a given timestamp.
74    #[must_use]
75    pub const fn from(start: i64) -> Self {
76        Self {
77            start: Some(start),
78            end: None,
79        }
80    }
81
82    /// Creates a time range ending at a given timestamp.
83    #[must_use]
84    pub const fn until(end: i64) -> Self {
85        Self {
86            start: None,
87            end: Some(end),
88        }
89    }
90
91    /// Creates a bounded time range.
92    #[must_use]
93    pub const fn between(start: i64, end: i64) -> Self {
94        Self {
95            start: Some(start),
96            end: Some(end),
97        }
98    }
99
100    /// Creates a time range starting from now.
101    #[must_use]
102    pub fn from_now() -> Self {
103        Self::from(current_timestamp())
104    }
105
106    /// Creates a time range ending now.
107    #[must_use]
108    pub fn until_now() -> Self {
109        Self::until(current_timestamp())
110    }
111
112    /// Checks if the given timestamp falls within this range.
113    ///
114    /// Uses half-open interval semantics: `[start, end)`.
115    #[must_use]
116    pub const fn contains(&self, timestamp: i64) -> bool {
117        let after_start = match self.start {
118            Some(s) => timestamp >= s,
119            None => true,
120        };
121        let before_end = match self.end {
122            Some(e) => timestamp < e,
123            None => true,
124        };
125        after_start && before_end
126    }
127
128    /// Checks if this range is currently valid.
129    #[must_use]
130    pub fn is_current(&self) -> bool {
131        self.contains(current_timestamp())
132    }
133
134    /// Checks if this range is unbounded on both ends.
135    #[must_use]
136    pub const fn is_unbounded(&self) -> bool {
137        self.start.is_none() && self.end.is_none()
138    }
139
140    /// Checks if this range has started (start is in the past or unbounded).
141    #[must_use]
142    pub fn has_started(&self) -> bool {
143        self.start.is_none_or(|s| current_timestamp() >= s)
144    }
145
146    /// Checks if this range has ended (end is in the past).
147    #[must_use]
148    pub fn has_ended(&self) -> bool {
149        self.end.is_some_and(|e| current_timestamp() >= e)
150    }
151
152    /// Returns the overlap of this range with another, if any.
153    #[must_use]
154    pub fn overlap(&self, other: &Self) -> Option<Self> {
155        let start = match (self.start, other.start) {
156            (Some(a), Some(b)) => Some(a.max(b)),
157            (Some(a), None) => Some(a),
158            (None, Some(b)) => Some(b),
159            (None, None) => None,
160        };
161
162        let end = match (self.end, other.end) {
163            (Some(a), Some(b)) => Some(a.min(b)),
164            (Some(a), None) => Some(a),
165            (None, Some(b)) => Some(b),
166            (None, None) => None,
167        };
168
169        // Check if the interval is valid (start < end)
170        if let (Some(s), Some(e)) = (start, end)
171            && s >= e
172        {
173            return None;
174        }
175
176        Some(Self { start, end })
177    }
178
179    /// Ends this range at the given timestamp.
180    ///
181    /// Useful for "closing" an open-ended range when a fact becomes invalid.
182    #[must_use]
183    pub const fn close_at(self, end: i64) -> Self {
184        Self {
185            start: self.start,
186            end: Some(end),
187        }
188    }
189
190    /// Ends this range now.
191    #[must_use]
192    pub fn close_now(self) -> Self {
193        self.close_at(current_timestamp())
194    }
195}
196
197impl Default for ValidTimeRange {
198    fn default() -> Self {
199        Self::unbounded()
200    }
201}
202
203impl fmt::Display for ValidTimeRange {
204    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
205        match (self.start, self.end) {
206            (None, None) => write!(f, "[∞, ∞)"),
207            (Some(s), None) => write!(f, "[{s}, ∞)"),
208            (None, Some(e)) => write!(f, "[∞, {e})"),
209            (Some(s), Some(e)) => write!(f, "[{s}, {e})"),
210        }
211    }
212}
213
214/// Represents when a fact was recorded in the system (transaction time).
215///
216/// Transaction time is immutable after creation - it captures the moment
217/// when the system learned about a fact. This enables point-in-time queries
218/// and auditing.
219#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
220pub struct TransactionTime {
221    /// Unix timestamp when the record was created.
222    timestamp: i64,
223}
224
225impl TransactionTime {
226    /// Creates a transaction time at the current moment.
227    #[must_use]
228    pub fn now() -> Self {
229        Self {
230            timestamp: current_timestamp(),
231        }
232    }
233
234    /// Creates a transaction time at a specific timestamp.
235    ///
236    /// This should only be used for importing historical data or testing.
237    #[must_use]
238    pub const fn at(timestamp: i64) -> Self {
239        Self { timestamp }
240    }
241
242    /// Returns the timestamp.
243    #[must_use]
244    pub const fn timestamp(&self) -> i64 {
245        self.timestamp
246    }
247
248    /// Checks if this transaction time is before another.
249    #[must_use]
250    pub const fn is_before(&self, other: &Self) -> bool {
251        self.timestamp < other.timestamp
252    }
253
254    /// Checks if this transaction time is after another.
255    #[must_use]
256    pub const fn is_after(&self, other: &Self) -> bool {
257        self.timestamp > other.timestamp
258    }
259
260    /// Checks if this transaction occurred before or at the given timestamp.
261    #[must_use]
262    pub const fn was_known_at(&self, timestamp: i64) -> bool {
263        self.timestamp <= timestamp
264    }
265}
266
267impl Default for TransactionTime {
268    fn default() -> Self {
269        Self::now()
270    }
271}
272
273impl fmt::Display for TransactionTime {
274    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
275        write!(f, "tx@{}", self.timestamp)
276    }
277}
278
279impl From<i64> for TransactionTime {
280    fn from(timestamp: i64) -> Self {
281        Self::at(timestamp)
282    }
283}
284
285/// A bitemporal point representing both valid and transaction time.
286///
287/// This is useful for querying "what was known to be true at time T1,
288/// as of system time T2?"
289#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
290pub struct BitemporalPoint {
291    /// Point in valid time to query.
292    pub valid_at: i64,
293    /// Point in transaction time to query.
294    pub as_of: i64,
295}
296
297impl BitemporalPoint {
298    /// Creates a bitemporal point.
299    #[must_use]
300    pub const fn new(valid_at: i64, as_of: i64) -> Self {
301        Self { valid_at, as_of }
302    }
303
304    /// Creates a bitemporal point for "now" in both dimensions.
305    #[must_use]
306    pub fn now() -> Self {
307        let ts = current_timestamp();
308        Self {
309            valid_at: ts,
310            as_of: ts,
311        }
312    }
313
314    /// Checks if a record with given temporal metadata is visible at this point.
315    #[must_use]
316    pub const fn is_visible(&self, valid_time: &ValidTimeRange, tx_time: &TransactionTime) -> bool {
317        valid_time.contains(self.valid_at) && tx_time.was_known_at(self.as_of)
318    }
319}
320
321impl Default for BitemporalPoint {
322    fn default() -> Self {
323        Self::now()
324    }
325}
326
327impl fmt::Display for BitemporalPoint {
328    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
329        write!(f, "valid@{}, as_of@{}", self.valid_at, self.as_of)
330    }
331}
332
333/// Returns the current Unix timestamp in seconds.
334#[must_use]
335#[allow(clippy::cast_possible_wrap)]
336pub fn current_timestamp() -> i64 {
337    // Cast is safe: u64::MAX seconds won't occur until year 292277026596
338    SystemTime::now()
339        .duration_since(UNIX_EPOCH)
340        .map(|d| d.as_secs() as i64)
341        .unwrap_or(0)
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347
348    #[test]
349    fn test_valid_time_range_unbounded() {
350        let range = ValidTimeRange::unbounded();
351        assert!(range.is_unbounded());
352        assert!(range.contains(0));
353        assert!(range.contains(i64::MAX));
354        assert!(range.contains(i64::MIN));
355    }
356
357    #[test]
358    fn test_valid_time_range_from() {
359        let range = ValidTimeRange::from(100);
360        assert!(!range.is_unbounded());
361        assert!(!range.contains(99));
362        assert!(range.contains(100));
363        assert!(range.contains(101));
364        assert!(range.contains(i64::MAX));
365    }
366
367    #[test]
368    fn test_valid_time_range_until() {
369        let range = ValidTimeRange::until(100);
370        assert!(!range.is_unbounded());
371        assert!(range.contains(99));
372        assert!(!range.contains(100)); // End is exclusive
373        assert!(range.contains(i64::MIN));
374    }
375
376    #[test]
377    fn test_valid_time_range_between() {
378        let range = ValidTimeRange::between(100, 200);
379        assert!(!range.is_unbounded());
380        assert!(!range.contains(99));
381        assert!(range.contains(100));
382        assert!(range.contains(150));
383        assert!(range.contains(199));
384        assert!(!range.contains(200)); // End is exclusive
385    }
386
387    #[test]
388    fn test_valid_time_range_overlap() {
389        let r1 = ValidTimeRange::between(100, 200);
390        let r2 = ValidTimeRange::between(150, 250);
391
392        let overlap = r1.overlap(&r2);
393        assert!(overlap.is_some());
394        let overlap = overlap.unwrap();
395        assert_eq!(overlap.start, Some(150));
396        assert_eq!(overlap.end, Some(200));
397    }
398
399    #[test]
400    fn test_valid_time_range_no_overlap() {
401        let r1 = ValidTimeRange::between(100, 200);
402        let r2 = ValidTimeRange::between(200, 300);
403
404        let overlap = r1.overlap(&r2);
405        assert!(overlap.is_none());
406    }
407
408    #[test]
409    fn test_valid_time_range_close() {
410        let range = ValidTimeRange::from(100);
411        assert!(range.end.is_none());
412
413        let closed = range.close_at(200);
414        assert_eq!(closed.start, Some(100));
415        assert_eq!(closed.end, Some(200));
416    }
417
418    #[test]
419    fn test_transaction_time() {
420        let tx = TransactionTime::now();
421        assert!(tx.timestamp() > 0);
422
423        let tx2 = TransactionTime::at(100);
424        assert_eq!(tx2.timestamp(), 100);
425        assert!(tx2.is_before(&tx));
426        assert!(tx.is_after(&tx2));
427    }
428
429    #[test]
430    fn test_transaction_time_was_known_at() {
431        let tx = TransactionTime::at(100);
432        assert!(tx.was_known_at(100));
433        assert!(tx.was_known_at(101));
434        assert!(!tx.was_known_at(99));
435    }
436
437    #[test]
438    fn test_bitemporal_point() {
439        let point = BitemporalPoint::new(150, 200);
440
441        // Record was valid from 100-200, created at tx_time 50
442        let valid_time = ValidTimeRange::between(100, 200);
443        let tx_time = TransactionTime::at(50);
444
445        // At point (valid_at=150, as_of=200):
446        // - 150 is in [100, 200) ✓
447        // - tx_time 50 <= as_of 200 ✓
448        assert!(point.is_visible(&valid_time, &tx_time));
449
450        // Record created after our as_of point
451        let tx_time_future = TransactionTime::at(250);
452        assert!(!point.is_visible(&valid_time, &tx_time_future));
453
454        // Record not valid at our valid_at point
455        let valid_time_past = ValidTimeRange::between(50, 100);
456        assert!(!point.is_visible(&valid_time_past, &tx_time));
457    }
458
459    #[test]
460    fn test_display_formats() {
461        assert_eq!(ValidTimeRange::unbounded().to_string(), "[∞, ∞)");
462        assert_eq!(ValidTimeRange::from(100).to_string(), "[100, ∞)");
463        assert_eq!(ValidTimeRange::until(200).to_string(), "[∞, 200)");
464        assert_eq!(ValidTimeRange::between(100, 200).to_string(), "[100, 200)");
465
466        assert_eq!(TransactionTime::at(100).to_string(), "tx@100");
467        assert_eq!(
468            BitemporalPoint::new(100, 200).to_string(),
469            "valid@100, as_of@200"
470        );
471    }
472
473    #[test]
474    fn test_defaults() {
475        let valid_time = ValidTimeRange::default();
476        assert!(valid_time.is_unbounded());
477
478        let tx_time = TransactionTime::default();
479        assert!(tx_time.timestamp() > 0);
480
481        let point = BitemporalPoint::default();
482        assert!(point.valid_at > 0);
483        assert!(point.as_of > 0);
484    }
485}