Skip to main content

mempill_types/
time.rs

1//! Temporal types: bi-temporal model support.
2
3/// Granularity of a valid-time date that was extracted from a partial date string.
4///
5/// When a host supplies a date like `"2024"` or `"2024-05"`, the engine normalises it to a
6/// `DateTime<Utc>` start-of-period but records the original precision here so that callers can
7/// render dates honestly (e.g. display `"2024"` instead of `"2024-01-01T00:00:00Z"`).
8///
9/// Additive field — existing `ValidTime` values without this field deserialise with `None`
10/// (see `#[serde(default)]` on [`ValidTime::start_granularity`] and [`ValidTime::end_granularity`]).
11#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum DateGranularity {
14    /// The date was given as a four-digit year, e.g. `"2024"`.
15    Year,
16    /// The date was given as year-month, e.g. `"2024-05"`.
17    Month,
18    /// The date was given as a full calendar date, e.g. `"2024-05-15"`.
19    Day,
20    /// The date was given as a full instant (date + time), e.g. an RFC-3339 string.
21    Instant,
22}
23
24/// Transaction-time stamp: machine-assigned, monotone, reliable. Engine-assigned; host cannot supply this as truth.
25#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
26#[serde(transparent)]
27pub struct TransactionTime(pub chrono::DateTime<chrono::Utc>);
28
29impl TransactionTime {
30    /// Stamp the current UTC instant.
31    pub fn now() -> Self {
32        Self(chrono::Utc::now())
33    }
34}
35
36/// Valid-time interval — fallible and host-extracted (confidence-tagged).
37/// When start/end are None, belief ordering falls back to TransactionTime.
38#[derive(Debug, Clone, PartialEq, Default, serde::Serialize, serde::Deserialize)]
39pub struct ValidTime {
40    /// Start of the valid-time window (`None` = unknown / open-ended).
41    pub start: Option<chrono::DateTime<chrono::Utc>>,
42    /// End of the valid-time window (`None` = unknown / open-ended).
43    pub end: Option<chrono::DateTime<chrono::Utc>>,
44    /// Confidence in the valid-time extraction itself (mirrors Confidence.valid_time_confidence).
45    pub valid_time_confidence: f32,
46    /// Optional precision hint for the `start` field when it was derived from a partial date
47    /// string (e.g. `"2024"` → `Year`, `"2024-05"` → `Month`).  `None` means the start was
48    /// either absent or already a full instant.
49    ///
50    /// Additive field: old serialised `ValidTime` values without this key deserialise to `None`.
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub start_granularity: Option<DateGranularity>,
53    /// Optional precision hint for the `end` field when it was derived from a partial date string.
54    /// `None` means the end was either absent (open-ended) or already a full instant.
55    ///
56    /// Additive field: old serialised `ValidTime` values without this key deserialise to `None`.
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub end_granularity: Option<DateGranularity>,
59}
60
61/// Convert a [`DateGranularity`] to its canonical TEXT representation used in both
62/// the SQLite and Postgres persistence adapters.
63///
64/// The strings are identical to the serde snake_case serialisation so that TEXT stored in
65/// the database matches JSON round-trips: `"year"`, `"month"`, `"day"`, `"instant"`.
66///
67/// Both adapters MUST use this function (not hand-rolled `match` arms) to guarantee
68/// cross-adapter encoding consistency.
69pub fn date_granularity_to_str(g: DateGranularity) -> &'static str {
70    match g {
71        DateGranularity::Year => "year",
72        DateGranularity::Month => "month",
73        DateGranularity::Day => "day",
74        DateGranularity::Instant => "instant",
75    }
76}
77
78/// Parse the TEXT column value back to [`DateGranularity`].
79///
80/// Returns `None` for `NULL` (old rows without granularity) and errors on unknown strings.
81/// Both adapters MUST use this function for decode symmetry with [`date_granularity_to_str`].
82pub fn str_to_date_granularity(s: &str) -> Option<DateGranularity> {
83    match s {
84        "year" => Some(DateGranularity::Year),
85        "month" => Some(DateGranularity::Month),
86        "day" => Some(DateGranularity::Day),
87        "instant" => Some(DateGranularity::Instant),
88        _ => None,
89    }
90}
91
92/// Parse a lenient date string into a UTC `DateTime` start-of-period and its [`DateGranularity`].
93///
94/// Accepted formats (in order):
95/// - `"YYYY"`         → 1 January of that year, midnight UTC  → [`DateGranularity::Year`]
96/// - `"YYYY-MM"`      → 1st of that month, midnight UTC       → [`DateGranularity::Month`]
97/// - `"YYYY-MM-DD"`   → that calendar day, midnight UTC       → [`DateGranularity::Day`]
98/// - Any RFC-3339 / ISO-8601 string with time component       → [`DateGranularity::Instant`]
99///
100/// Returns `None` if the input does not match any recognised format.
101///
102/// # Examples
103/// ```
104/// use mempill_types::time::parse_valid_time_date;
105/// use mempill_types::time::DateGranularity;
106/// let (dt, gran) = parse_valid_time_date("2024").unwrap();
107/// assert_eq!(gran, DateGranularity::Year);
108/// assert_eq!(dt.to_rfc3339(), "2024-01-01T00:00:00+00:00");
109/// ```
110pub fn parse_valid_time_date(
111    input: &str,
112) -> Option<(chrono::DateTime<chrono::Utc>, DateGranularity)> {
113    use chrono::{NaiveDate, NaiveDateTime, TimeZone, Utc};
114
115    let s = input.trim();
116
117    // Try RFC-3339 / full instant first (most specific).
118    if let Ok(dt) = s.parse::<chrono::DateTime<chrono::Utc>>() {
119        return Some((dt, DateGranularity::Instant));
120    }
121
122    // YYYY-MM-DD
123    if s.len() == 10 && s.chars().nth(4) == Some('-') && s.chars().nth(7) == Some('-') {
124        if let Ok(nd) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
125            let ndt = NaiveDateTime::new(nd, chrono::NaiveTime::from_hms_opt(0, 0, 0)?);
126            return Some((Utc.from_utc_datetime(&ndt), DateGranularity::Day));
127        }
128    }
129
130    // YYYY-MM
131    if s.len() == 7 && s.chars().nth(4) == Some('-') {
132        let padded = format!("{s}-01");
133        if let Ok(nd) = NaiveDate::parse_from_str(&padded, "%Y-%m-%d") {
134            let ndt = NaiveDateTime::new(nd, chrono::NaiveTime::from_hms_opt(0, 0, 0)?);
135            return Some((Utc.from_utc_datetime(&ndt), DateGranularity::Month));
136        }
137    }
138
139    // YYYY
140    if s.len() == 4 && s.chars().all(|c| c.is_ascii_digit()) {
141        if let Ok(year) = s.parse::<i32>() {
142            let nd = NaiveDate::from_ymd_opt(year, 1, 1)?;
143            let ndt = NaiveDateTime::new(nd, chrono::NaiveTime::from_hms_opt(0, 0, 0)?);
144            return Some((Utc.from_utc_datetime(&ndt), DateGranularity::Year));
145        }
146    }
147
148    None
149}
150
151/// Render a valid-time endpoint honestly at its recorded precision.
152///
153/// The granularity governs how many date components are included in the output.
154/// The hard rule: output MUST NOT fabricate finer precision than `granularity`.
155///
156/// | Granularity       | Example output        |
157/// |-------------------|-----------------------|
158/// | `Year`            | `"2020"`              |
159/// | `Month`           | `"2020-03"`           |
160/// | `Day`             | `"2020-03-15"`        |
161/// | `Instant`         | `"2020-03-15"` (day form; sub-day precision is not shown by default) |
162/// | `None` (unknown)  | falls back to `"YYYY-MM-DD"` day form, or `None` when `date` is also `None` |
163///
164/// Returns `None` when `date` is `None` (open / unknown endpoint).
165///
166/// # Example
167///
168/// ```
169/// use chrono::{TimeZone, Utc};
170/// use mempill_types::time::{DateGranularity, format_valid_time_endpoint};
171///
172/// let dt = Utc.with_ymd_and_hms(2020, 3, 15, 0, 0, 0).unwrap();
173/// assert_eq!(format_valid_time_endpoint(Some(dt), Some(DateGranularity::Month)), Some("2020-03".to_string()));
174/// assert_eq!(format_valid_time_endpoint(Some(dt), Some(DateGranularity::Year)),  Some("2020".to_string()));
175/// assert_eq!(format_valid_time_endpoint(Some(dt), Some(DateGranularity::Day)),   Some("2020-03-15".to_string()));
176/// assert_eq!(format_valid_time_endpoint(None, Some(DateGranularity::Month)), None);
177/// ```
178pub fn format_valid_time_endpoint(
179    date: Option<chrono::DateTime<chrono::Utc>>,
180    granularity: Option<DateGranularity>,
181) -> Option<String> {
182    let dt = date?;
183    Some(match granularity {
184        Some(DateGranularity::Year) => format!("{}", dt.format("%Y")),
185        Some(DateGranularity::Month) => format!("{}", dt.format("%Y-%m")),
186        // Day and Instant both render at day precision — Instant sub-day detail is
187        // intentionally omitted here to avoid surfacing fabricated precision for dates
188        // that were normalised to midnight UTC during ingestion.
189        Some(DateGranularity::Day) | Some(DateGranularity::Instant) => {
190            format!("{}", dt.format("%Y-%m-%d"))
191        }
192        // None granularity (legacy row or unknown precision): fall back to day form.
193        None => format!("{}", dt.format("%Y-%m-%d")),
194    })
195}
196
197impl ValidTime {
198    /// Returns true iff both start and end are None (unknown valid-time window).
199    pub fn is_unknown(&self) -> bool {
200        self.start.is_none() && self.end.is_none()
201    }
202
203    /// Returns true iff the interval is temporally incoherent: start > end,
204    /// or start > tx_time (valid-time boundary must predate or equal the time it was learned).
205    pub fn is_temporally_incoherent(&self, tx_time: &TransactionTime) -> bool {
206        if let (Some(s), Some(e)) = (self.start, self.end) {
207            if s > e {
208                return true;
209            }
210        }
211        if let Some(s) = self.start {
212            if s > tx_time.0 {
213                return true;
214            }
215        }
216        false
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use chrono::Utc;
224
225    #[test]
226    fn valid_time_unknown_when_both_none() {
227        let vt = ValidTime { start: None, end: None, valid_time_confidence: 0.0 , start_granularity: None, end_granularity: None};
228        assert!(vt.is_unknown());
229    }
230
231    #[test]
232    fn valid_time_not_unknown_when_start_set() {
233        let vt = ValidTime { start: Some(Utc::now()), end: None, valid_time_confidence: 0.8 , start_granularity: None, end_granularity: None};
234        assert!(!vt.is_unknown());
235    }
236
237    #[test]
238    fn incoherent_when_start_after_end() {
239        let now = Utc::now();
240        let tx = TransactionTime(now);
241        let vt = ValidTime {
242            start: Some(now + chrono::Duration::hours(1)),
243            end: Some(now),
244            valid_time_confidence: 1.0,
245            start_granularity: None, end_granularity: None,
246        };
247        assert!(vt.is_temporally_incoherent(&tx));
248    }
249
250    #[test]
251    fn incoherent_when_valid_start_after_tx_time() {
252        let now = Utc::now();
253        let tx = TransactionTime(now);
254        let vt = ValidTime {
255            start: Some(now + chrono::Duration::hours(1)),
256            end: None,
257            valid_time_confidence: 1.0,
258            start_granularity: None, end_granularity: None,
259        };
260        assert!(vt.is_temporally_incoherent(&tx));
261    }
262
263    #[test]
264    fn coherent_normal_interval() {
265        let now = Utc::now();
266        let tx = TransactionTime(now);
267        let vt = ValidTime {
268            start: Some(now - chrono::Duration::days(1)),
269            end: Some(now),
270            valid_time_confidence: 0.9,
271            start_granularity: None, end_granularity: None,
272        };
273        assert!(!vt.is_temporally_incoherent(&tx));
274    }
275
276    #[test]
277    fn transaction_time_ordering() {
278        let t1 = TransactionTime(Utc::now());
279        let t2 = TransactionTime(Utc::now() + chrono::Duration::seconds(1));
280        assert!(t1 < t2);
281    }
282
283    #[test]
284    fn transaction_time_serializes_as_bare_iso8601_string() {
285        use chrono::TimeZone;
286        // Fixed timestamp: 2024-01-15T12:00:00Z
287        let dt = Utc.with_ymd_and_hms(2024, 1, 15, 12, 0, 0).unwrap();
288        let tt = TransactionTime(dt);
289        let json = serde_json::to_string(&tt).unwrap();
290        // chrono serializes DateTime<Utc> as RFC3339/ISO-8601 string
291        assert!(json.starts_with('"'), "expected a bare JSON string, got: {json}");
292        assert!(json.contains("2024-01-15"), "expected date in serialized form, got: {json}");
293        let back: TransactionTime = serde_json::from_str(&json).unwrap();
294        assert_eq!(tt, back);
295    }
296
297    #[test]
298    fn valid_time_round_trip_serde() {
299        let vt = ValidTime { start: Some(Utc::now()), end: None, valid_time_confidence: 0.7 , start_granularity: None, end_granularity: None};
300        let json = serde_json::to_string(&vt).unwrap();
301        let back: ValidTime = serde_json::from_str(&json).unwrap();
302        assert_eq!(vt.start, back.start);
303        assert_eq!(vt.end, back.end);
304    }
305
306    // ── W1c — DateGranularity + parse_valid_time_date ────────────────────────
307
308    /// Old-format ValidTime (no granularity fields) must deserialise with both granularities = None.
309    #[test]
310    fn valid_time_old_format_compat_no_granularity_field() {
311        let old_json = r#"{"start":null,"end":null,"valid_time_confidence":0.0}"#;
312        let vt: ValidTime = serde_json::from_str(old_json).unwrap();
313        assert_eq!(vt.start_granularity, None, "old-format ValidTime must deserialise start_granularity=None");
314        assert_eq!(vt.end_granularity, None, "old-format ValidTime must deserialise end_granularity=None");
315    }
316
317    /// ValidTime with start_granularity=Year round-trips correctly.
318    #[test]
319    fn valid_time_with_granularity_round_trips() {
320        use chrono::TimeZone;
321        let dt = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
322        let vt = ValidTime {
323            start: Some(dt),
324            end: None,
325            valid_time_confidence: 0.9,
326            start_granularity: Some(DateGranularity::Year),
327            end_granularity: None,
328        };
329        let json = serde_json::to_string(&vt).unwrap();
330        let back: ValidTime = serde_json::from_str(&json).unwrap();
331        assert_eq!(back.start_granularity, Some(DateGranularity::Year));
332        assert_eq!(back.end_granularity, None);
333        assert_eq!(back.start, Some(dt));
334    }
335
336    /// ValidTime with both start and end granularity round-trips correctly.
337    #[test]
338    fn valid_time_with_both_granularities_round_trips() {
339        use chrono::TimeZone;
340        let start = Utc.with_ymd_and_hms(2020, 3, 1, 0, 0, 0).unwrap();
341        let end = Utc.with_ymd_and_hms(2023, 1, 1, 0, 0, 0).unwrap();
342        let vt = ValidTime {
343            start: Some(start),
344            end: Some(end),
345            valid_time_confidence: 0.9,
346            start_granularity: Some(DateGranularity::Month),
347            end_granularity: Some(DateGranularity::Year),
348        };
349        let json = serde_json::to_string(&vt).unwrap();
350        let back: ValidTime = serde_json::from_str(&json).unwrap();
351        assert_eq!(back.start_granularity, Some(DateGranularity::Month));
352        assert_eq!(back.end_granularity, Some(DateGranularity::Year));
353    }
354
355    /// parse_valid_time_date("YYYY") produces Year granularity and Jan 1 midnight UTC.
356    #[test]
357    fn parse_year_only() {
358        let (dt, gran) = parse_valid_time_date("2024").unwrap();
359        assert_eq!(gran, DateGranularity::Year);
360        assert_eq!(dt.to_rfc3339(), "2024-01-01T00:00:00+00:00");
361    }
362
363    /// parse_valid_time_date("YYYY-MM") produces Month granularity and 1st-of-month midnight UTC.
364    #[test]
365    fn parse_year_month() {
366        let (dt, gran) = parse_valid_time_date("2024-05").unwrap();
367        assert_eq!(gran, DateGranularity::Month);
368        assert_eq!(dt.to_rfc3339(), "2024-05-01T00:00:00+00:00");
369    }
370
371    /// parse_valid_time_date("YYYY-MM-DD") produces Day granularity and midnight UTC.
372    #[test]
373    fn parse_year_month_day() {
374        let (dt, gran) = parse_valid_time_date("2024-05-15").unwrap();
375        assert_eq!(gran, DateGranularity::Day);
376        assert_eq!(dt.to_rfc3339(), "2024-05-15T00:00:00+00:00");
377    }
378
379    /// parse_valid_time_date with an RFC-3339 string produces Instant granularity.
380    #[test]
381    fn parse_full_instant() {
382        let (dt, gran) = parse_valid_time_date("2024-05-15T10:30:00Z").unwrap();
383        assert_eq!(gran, DateGranularity::Instant);
384        assert_eq!(dt.to_rfc3339(), "2024-05-15T10:30:00+00:00");
385    }
386
387    /// Garbage input returns None.
388    #[test]
389    fn parse_invalid_returns_none() {
390        assert!(parse_valid_time_date("not-a-date").is_none());
391        assert!(parse_valid_time_date("").is_none());
392        assert!(parse_valid_time_date("24-05").is_none());
393    }
394
395    /// DateGranularity serde uses snake_case.
396    #[test]
397    fn date_granularity_serde_snake_case() {
398        let g = DateGranularity::Year;
399        let json = serde_json::to_string(&g).unwrap();
400        assert_eq!(json, r#""year""#);
401        let back: DateGranularity = serde_json::from_str(&json).unwrap();
402        assert_eq!(back, DateGranularity::Year);
403    }
404
405    /// Granularity fields are omitted from JSON when both are None (skip_serializing_if).
406    #[test]
407    fn granularity_none_not_serialised() {
408        let vt = ValidTime { start: None, end: None, valid_time_confidence: 0.0, start_granularity: None, end_granularity: None };
409        let json = serde_json::to_string(&vt).unwrap();
410        assert!(!json.contains("granularity"), "None granularity fields must not appear in serialised JSON");
411    }
412
413    /// start_granularity Some(Month) IS serialised; end_granularity None is omitted.
414    #[test]
415    fn granularity_some_is_serialised() {
416        use chrono::TimeZone;
417        let vt = ValidTime {
418            start: Some(Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap()),
419            end: None,
420            valid_time_confidence: 0.9,
421            start_granularity: Some(DateGranularity::Month),
422            end_granularity: None,
423        };
424        let json = serde_json::to_string(&vt).unwrap();
425        assert!(json.contains("start_granularity"), "Some start_granularity must appear in serialised JSON");
426        assert!(json.contains("month"), "granularity value must be 'month'");
427        assert!(!json.contains("end_granularity"), "None end_granularity must not appear in serialised JSON");
428    }
429
430    // ── W5 — format_valid_time_endpoint render helper ────────────────────────
431
432    /// Year granularity: only year component emitted, never month or day.
433    #[test]
434    fn render_helper_year_granularity_no_month_day() {
435        use chrono::TimeZone;
436        let dt = Utc.with_ymd_and_hms(2020, 3, 15, 0, 0, 0).unwrap();
437        let out = format_valid_time_endpoint(Some(dt), Some(DateGranularity::Year)).unwrap();
438        assert_eq!(out, "2020", "Year granularity must output only YYYY");
439        assert!(!out.contains('-'), "Year output must not contain a dash (no month/day)");
440    }
441
442    /// Month granularity: year and month emitted, never day.
443    #[test]
444    fn render_helper_month_granularity_no_day() {
445        use chrono::TimeZone;
446        let dt = Utc.with_ymd_and_hms(2020, 3, 15, 0, 0, 0).unwrap();
447        let out = format_valid_time_endpoint(Some(dt), Some(DateGranularity::Month)).unwrap();
448        assert_eq!(out, "2020-03", "Month granularity must output YYYY-MM");
449        // Must not contain a day component — splitting by '-' gives ["2020","03"] only.
450        let parts: Vec<&str> = out.splitn(4, '-').collect();
451        assert_eq!(parts.len(), 2, "Month output must have exactly two dash-separated parts (YYYY-MM)");
452    }
453
454    /// Day granularity: full YYYY-MM-DD.
455    #[test]
456    fn render_helper_day_granularity_full_date() {
457        use chrono::TimeZone;
458        let dt = Utc.with_ymd_and_hms(2020, 3, 15, 0, 0, 0).unwrap();
459        let out = format_valid_time_endpoint(Some(dt), Some(DateGranularity::Day)).unwrap();
460        assert_eq!(out, "2020-03-15");
461    }
462
463    /// Instant granularity: rendered at day precision (sub-day suppressed to avoid
464    /// fabricating precision when dates were normalised to midnight UTC at ingestion).
465    #[test]
466    fn render_helper_instant_granularity_renders_at_day() {
467        use chrono::TimeZone;
468        let dt = Utc.with_ymd_and_hms(2020, 3, 15, 10, 30, 0).unwrap();
469        let out = format_valid_time_endpoint(Some(dt), Some(DateGranularity::Instant)).unwrap();
470        assert_eq!(out, "2020-03-15", "Instant renders at day precision");
471    }
472
473    /// None granularity (legacy / unknown precision): falls back to day form.
474    #[test]
475    fn render_helper_none_granularity_falls_back_to_day() {
476        use chrono::TimeZone;
477        let dt = Utc.with_ymd_and_hms(2020, 3, 15, 0, 0, 0).unwrap();
478        let out = format_valid_time_endpoint(Some(dt), None).unwrap();
479        assert_eq!(out, "2020-03-15", "None granularity must fall back to YYYY-MM-DD");
480    }
481
482    /// None date returns None regardless of granularity.
483    #[test]
484    fn render_helper_none_date_returns_none() {
485        assert_eq!(format_valid_time_endpoint(None, Some(DateGranularity::Month)), None);
486        assert_eq!(format_valid_time_endpoint(None, None), None);
487    }
488}