Skip to main content

scone_core/
timeparse.rs

1//! Date references in natural-language queries (E12): "in May 2023",
2//! "on 2023/05/20", "2023-05-20". Real memory questions are
3//! time-anchored; retrieval that ignores their dates leaves the temporal
4//! class on the floor (measured 68% all-evidence, 2026-08-28).
5
6/// An inclusive ISO date window referenced by a query.
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct DateWindow {
9    pub start: String,
10    pub end: String,
11}
12
13const MONTHS: [(&str, u32); 12] = [
14    ("january", 1),
15    ("february", 2),
16    ("march", 3),
17    ("april", 4),
18    ("may", 5),
19    ("june", 6),
20    ("july", 7),
21    ("august", 8),
22    ("september", 9),
23    ("october", 10),
24    ("november", 11),
25    ("december", 12),
26];
27
28fn days_in_month(year: i32, month: u32) -> u32 {
29    match month {
30        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
31        4 | 6 | 9 | 11 => 30,
32        2 if year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) => 29,
33        _ => 28,
34    }
35}
36
37/// Extract explicit date references from a query.
38pub fn date_windows(query: &str) -> Vec<DateWindow> {
39    let lower = query.to_lowercase();
40    let mut windows = Vec::new();
41
42    // Numeric dates: YYYY/MM/DD or YYYY-MM-DD.
43    let bytes = lower.as_bytes();
44    let mut i = 0;
45    while i + 10 <= bytes.len() {
46        let slice = &lower[i..i + 10];
47        let sep_ok = matches!(slice.as_bytes()[4], b'/' | b'-')
48            && slice.as_bytes()[7] == slice.as_bytes()[4];
49        if sep_ok
50            && slice[..4].chars().all(|c| c.is_ascii_digit())
51            && slice[5..7].chars().all(|c| c.is_ascii_digit())
52            && slice[8..10].chars().all(|c| c.is_ascii_digit())
53        {
54            let day = format!("{}-{}-{}", &slice[..4], &slice[5..7], &slice[8..10]);
55            windows.push(DateWindow {
56                start: format!("{day}T00:00:00Z"),
57                end: format!("{day}T23:59:59Z"),
58            });
59            i += 10;
60            continue;
61        }
62        i += 1;
63    }
64
65    // "May 2023" style month references.
66    let tokens: Vec<&str> = lower
67        .split(|c: char| !c.is_ascii_alphanumeric())
68        .filter(|t| !t.is_empty())
69        .collect();
70    for pair in tokens.windows(2) {
71        if let Some((_, month)) = MONTHS.iter().find(|(name, _)| *name == pair[0])
72            && pair[1].len() == 4
73            && pair[1].chars().all(|c| c.is_ascii_digit())
74            && let Ok(year) = pair[1].parse::<i32>()
75        {
76            windows.push(DateWindow {
77                start: format!("{year:04}-{month:02}-01T00:00:00Z"),
78                end: format!(
79                    "{year:04}-{month:02}-{:02}T23:59:59Z",
80                    days_in_month(year, *month)
81                ),
82            });
83        }
84    }
85    windows
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91
92    #[test]
93    fn parses_numeric_and_month_references() {
94        let w = date_windows("what did I say on 2023/05/20 about the trip");
95        assert_eq!(w.len(), 1);
96        assert_eq!(w[0].start, "2023-05-20T00:00:00Z");
97        let w = date_windows("decisions from May 2023 and June 2024?");
98        assert_eq!(w.len(), 2);
99        assert_eq!(w[0].end, "2023-05-31T23:59:59Z");
100        assert_eq!(w[1].end, "2024-06-30T23:59:59Z");
101        let w = date_windows("iso style 2024-02-29 leap day");
102        assert_eq!(w[0].start, "2024-02-29T00:00:00Z");
103    }
104
105    #[test]
106    fn plain_queries_have_no_windows() {
107        assert!(date_windows("what tools does mark prefer").is_empty());
108        assert!(date_windows("may I ask about the 2023 goals").is_empty());
109    }
110}