Skip to main content

topodb_json/
temporal.rs

1//! Deterministic temporal query rewriting for temporal recall: extract ONE
2//! temporal phrase from a search query and resolve it to a created-time
3//! range. Pure — `now_ms` is always injected by the caller; this module
4//! never reads a clock, so every result (and every test) is reproducible.
5//!
6//! Recognized phrases (case-insensitive; the first rule in priority order
7//! that matches anywhere in the query wins):
8//!
9//! 1. `between <date> and <date>`      → `[start(a), end(b))` — both
10//!    endpoint periods included
11//! 2. `before <date>` / `until <date>` → `[.., start(date))`
12//! 3. `after <date>` / `since <date>`  → `[start(date), ..)` — inclusive
13//! 4. `last <N> days`                  → `[today_start − N days, ..)`
14//! 5. `yesterday` | `today` | `last week` | `last month`
15//! 6. bare `<date>`, optionally after `in`/`on`/`during` → `[start, end)`
16//!
17//! `<date>` is ISO — `2026-08-01`, `2026-08`, or `2026` — with years
18//! restricted to 1970–2099 so ports and issue numbers never parse as
19//! years. Date-only bounds resolve to UTC day/month/year boundaries per
20//! the spec: `before 2026-08-01` excludes that entire day, `after
21//! 2026-08-01` includes it. Rolling windows anchor at the start of the UTC
22//! day containing `now_ms`: `last week` = last 7 days, `last month` = last
23//! 30 days.
24//!
25//! Conservative by design, mirroring the spec: no recognized phrase
26//! ("before the v8 migration"), a calendar-invalid date, an inverted
27//! `between`, or a residual query left empty by the strip ("last week"
28//! alone) all return `None` — the caller searches its original query
29//! unmodified.
30
31use regex::Regex;
32use std::sync::OnceLock;
33
34const DAY_MS: i64 = 86_400_000;
35
36/// A temporal phrase resolved against the injected reference `now_ms`.
37#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
38pub struct TemporalRewrite {
39    /// The query with the matched phrase removed and whitespace collapsed.
40    pub residual_query: String,
41    /// Keep nodes created at or after this UTC ms timestamp (`None` = unbounded).
42    pub after_ms: Option<i64>,
43    /// Keep nodes created strictly before this UTC ms timestamp (`None` = unbounded).
44    pub before_ms: Option<i64>,
45    /// Exactly what matched, original casing preserved (surfaces later in
46    /// `applied_time_filter.matched_phrase`).
47    pub matched_phrase: String,
48}
49
50/// Priority order is load-bearing: prefixed forms must outrank the bare
51/// date, or `since 2026-08-01` would strip only the date and leave "since"
52/// in the residual.
53#[derive(Clone, Copy)]
54enum Rule {
55    Between,
56    Before,
57    After,
58    LastNDays,
59    Relative,
60    BareDate,
61}
62
63fn rules() -> &'static [(Rule, Regex)] {
64    static RULES: OnceLock<Vec<(Rule, Regex)>> = OnceLock::new();
65    RULES.get_or_init(|| {
66        // Three capture groups per <date>: (year)(-month)?(-day)?, with the
67        // year bounded to 1970–2099.
68        let date = r"(19[7-9]\d|20\d{2})(?:-(\d{2})(?:-(\d{2}))?)?";
69        // Compile-time-constant patterns, each exercised by the test table —
70        // the `expect` is unreachable (crate rule: nothing here panics on
71        // caller input; this is not caller input).
72        let re = |p: &str| Regex::new(p).expect("static temporal pattern");
73        vec![
74            (
75                Rule::Between,
76                re(&format!(r"(?i)\bbetween\s+{date}\s+and\s+{date}\b")),
77            ),
78            (
79                Rule::Before,
80                re(&format!(r"(?i)\b(?:before|until)\s+{date}\b")),
81            ),
82            (
83                Rule::After,
84                re(&format!(r"(?i)\b(?:after|since)\s+{date}\b")),
85            ),
86            (Rule::LastNDays, re(r"(?i)\blast\s+(\d{1,4})\s+days?\b")),
87            (
88                Rule::Relative,
89                re(r"(?i)\b(yesterday|today|last\s+week|last\s+month)\b"),
90            ),
91            (
92                Rule::BareDate,
93                re(&format!(r"(?i)\b((?:in|on|during)\s+)?{date}\b")),
94            ),
95        ]
96    })
97}
98
99/// One `<date>` at day, month, or year granularity, already range-checked.
100#[derive(Clone, Copy)]
101enum DateSpec {
102    Day { y: i64, m: i64, d: i64 },
103    Month { y: i64, m: i64 },
104    Year { y: i64 },
105}
106
107impl DateSpec {
108    /// Read the three-group `<date>` starting at capture index `i`,
109    /// rejecting calendar-invalid combinations (month 13, Feb 30).
110    fn read(caps: &regex::Captures<'_>, i: usize) -> Option<Self> {
111        let y: i64 = caps.get(i)?.as_str().parse().ok()?;
112        let m: i64 = match caps.get(i + 1) {
113            Some(m) => m.as_str().parse().ok()?,
114            None => return Some(DateSpec::Year { y }),
115        };
116        if !(1..=12).contains(&m) {
117            return None;
118        }
119        let d: i64 = match caps.get(i + 2) {
120            Some(d) => d.as_str().parse().ok()?,
121            None => return Some(DateSpec::Month { y, m }),
122        };
123        (1..=days_in_month(y, m))
124            .contains(&d)
125            .then_some(DateSpec::Day { y, m, d })
126    }
127
128    /// UTC ms of the period's first instant (`2026-08` → Aug 1 00:00:00Z).
129    fn start_ms(self) -> i64 {
130        match self {
131            DateSpec::Day { y, m, d } => days_from_civil(y, m, d) * DAY_MS,
132            DateSpec::Month { y, m } => days_from_civil(y, m, 1) * DAY_MS,
133            DateSpec::Year { y } => days_from_civil(y, 1, 1) * DAY_MS,
134        }
135    }
136
137    /// UTC ms just past the period's last instant (exclusive end).
138    fn end_ms(self) -> i64 {
139        match self {
140            DateSpec::Day { y, m, d } => (days_from_civil(y, m, d) + 1) * DAY_MS,
141            DateSpec::Month { y, m: 12 } => days_from_civil(y + 1, 1, 1) * DAY_MS,
142            DateSpec::Month { y, m } => days_from_civil(y, m + 1, 1) * DAY_MS,
143            DateSpec::Year { y } => days_from_civil(y + 1, 1, 1) * DAY_MS,
144        }
145    }
146}
147
148/// Days since 1970-01-01 for a Gregorian civil date (Howard Hinnant's
149/// `days_from_civil`; the year is regex-bounded to 1970–2099, so the
150/// non-negative-era simplification holds).
151fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
152    let y = if m <= 2 { y - 1 } else { y };
153    let era = y / 400;
154    let yoe = y - era * 400;
155    let mp = if m > 2 { m - 3 } else { m + 9 };
156    let doy = (153 * mp + 2) / 5 + d - 1;
157    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
158    era * 146_097 + doe - 719_468
159}
160
161/// Only ever called with `m` in 1..=12 (validated in `read`).
162fn days_in_month(y: i64, m: i64) -> i64 {
163    match m {
164        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
165        4 | 6 | 9 | 11 => 30,
166        _ => {
167            if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 {
168                29
169            } else {
170                28
171            }
172        }
173    }
174}
175
176/// Extract one temporal phrase from `query`, resolved against `now_ms`
177/// (unix ms, UTC). `None` means the caller searches its original query
178/// unmodified — see the module docs for the grammar and boundary rules.
179/// Pure and deterministic: same `(query, now_ms)` → same result; no I/O,
180/// no clock reads.
181pub fn parse_temporal_query(query: &str, now_ms: i64) -> Option<TemporalRewrite> {
182    let today = now_ms.div_euclid(DAY_MS) * DAY_MS;
183    for (rule, re) in rules() {
184        let caps = match re.captures(query) {
185            Some(c) => c,
186            None => continue,
187        };
188        // A rule that matched but carries an invalid or inverted date
189        // aborts the whole parse (conservative), rather than falling
190        // through to a lower-priority partial reading of the same text.
191        let (after_ms, before_ms) = match rule {
192            Rule::Between => {
193                let a = DateSpec::read(&caps, 1)?;
194                let b = DateSpec::read(&caps, 4)?;
195                if a.start_ms() >= b.end_ms() {
196                    return None;
197                }
198                (Some(a.start_ms()), Some(b.end_ms()))
199            }
200            Rule::Before => (None, Some(DateSpec::read(&caps, 1)?.start_ms())),
201            Rule::After => (Some(DateSpec::read(&caps, 1)?.start_ms()), None),
202            Rule::LastNDays => {
203                let n: i64 = caps[1].parse().ok()?;
204                if n == 0 {
205                    return None;
206                }
207                (Some(today - n * DAY_MS), None)
208            }
209            Rule::Relative => {
210                // Normalize interior whitespace ("last  week") and casing.
211                let key = caps[1].to_ascii_lowercase();
212                match key
213                    .split_whitespace()
214                    .collect::<Vec<_>>()
215                    .join(" ")
216                    .as_str()
217                {
218                    "yesterday" => (Some(today - DAY_MS), Some(today)),
219                    "today" => (Some(today), Some(today + DAY_MS)),
220                    "last week" => (Some(today - 7 * DAY_MS), None),
221                    "last month" => (Some(today - 30 * DAY_MS), None),
222                    // The alternation admits nothing else; conservative
223                    // None instead of a panic per the crate's no-panic rule.
224                    _ => return None,
225                }
226            }
227            Rule::BareDate => {
228                let d = DateSpec::read(&caps, 2)?;
229                // Reject year-only dates without a temporal preposition (too ambiguous in prose).
230                if matches!(d, DateSpec::Year { .. }) && caps.get(1).is_none() {
231                    return None;
232                }
233                (Some(d.start_ms()), Some(d.end_ms()))
234            }
235        };
236        let whole = caps.get(0)?;
237        let residual = format!("{} {}", &query[..whole.start()], &query[whole.end()..])
238            .split_whitespace()
239            .collect::<Vec<_>>()
240            .join(" ");
241        if residual.is_empty() {
242            // A pure temporal phrase is not a searchable query.
243            return None;
244        }
245        if !residual.contains(|c: char| c.is_ascii_alphanumeric()) {
246            // A residual with no analyzable tokens (e.g. "!!!") would be
247            // REJECTED by the engine's tokenizer — before the rewriter
248            // existed the date words themselves were searchable, so
249            // rewriting here would turn a working query into an error.
250            // Pass the original through unrewritten instead.
251            return None;
252        }
253        return Some(TemporalRewrite {
254            residual_query: residual,
255            after_ms,
256            before_ms,
257            matched_phrase: whole.as_str().to_string(),
258        });
259    }
260    None
261}
262
263/// Resolve an explicit ISO bound to UTC ms. Accepts a date — `2026-08-01`,
264/// `2026-08`, or `2026`, resolving to the start of its period — or a UTC
265/// datetime `YYYY-MM-DDTHH:MM[:SS]` with an optional trailing `Z`,
266/// resolving to that exact instant (non-UTC offsets and fractional seconds
267/// are rejected: a bound silently shifted by timezone math would be worse
268/// than an error). `None` for anything else. Shared by the MCP
269/// `created_after`/`created_before` params and the CLI `--created-*` flags
270/// so explicit bounds and the rewriter resolve dates identically; the
271/// rewriter itself matches dates only — a datetime inside prose does not
272/// trigger a rewrite.
273pub fn parse_iso_instant(s: &str) -> Option<i64> {
274    static RE: OnceLock<Regex> = OnceLock::new();
275    let re = RE.get_or_init(|| {
276        Regex::new(
277            r"^\s*(19[7-9]\d|20\d{2})(?:-(\d{2})(?:-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?Z?)?)?)?\s*$",
278        )
279        .expect("static temporal pattern")
280    });
281    let caps = re.captures(s)?;
282    let date_ms = DateSpec::read(&caps, 1).map(DateSpec::start_ms)?;
283    match (caps.get(4), caps.get(5)) {
284        (Some(h), Some(m)) => {
285            let (h, m): (i64, i64) = (h.as_str().parse().ok()?, m.as_str().parse().ok()?);
286            let sec: i64 = caps.get(6).map_or(Some(0), |x| x.as_str().parse().ok())?;
287            if h > 23 || m > 59 || sec > 59 {
288                return None;
289            }
290            Some(date_ms + (h * 3600 + m * 60 + sec) * 1000)
291        }
292        _ => Some(date_ms),
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    type TestCase<'a> = (&'a str, &'a str, Option<i64>, Option<i64>, &'a str);
301
302    /// 2026-08-09T12:00:00Z — a fixed reference "now" (day 20674 since the
303    /// epoch; the containing UTC day starts at 1_786_233_600_000). Every
304    /// expectation below is hand-derived from these anchors — no clock,
305    /// no chrono.
306    const NOW_MS: i64 = 1_786_276_800_000;
307    const TODAY: i64 = 1_786_233_600_000;
308    const DAY: i64 = 86_400_000;
309
310    #[track_caller]
311    fn parse(q: &str) -> TemporalRewrite {
312        parse_temporal_query(q, NOW_MS).unwrap_or_else(|| panic!("expected a rewrite for {q:?}"))
313    }
314
315    fn assert_cases(cases: &[TestCase]) {
316        for (query, residual, after, before, phrase) in cases {
317            let got = parse(query);
318            assert_eq!(got.residual_query, *residual, "residual for {query:?}");
319            assert_eq!(got.after_ms, *after, "after_ms for {query:?}");
320            assert_eq!(got.before_ms, *before, "before_ms for {query:?}");
321            assert_eq!(got.matched_phrase, *phrase, "phrase for {query:?}");
322        }
323    }
324
325    #[test]
326    fn absolute_forms_resolve_to_utc_boundaries() {
327        // (query, residual, after_ms, before_ms, matched_phrase)
328        // 2026-01-01 = 1_767_225_600_000, 2026-03-02 = 1_772_409_600_000,
329        // 2026-08-01 = 1_785_542_400_000, 2026-08-02 = 1_785_628_800_000,
330        // 2026-09-01 = 1_788_220_800_000 (all UTC day starts).
331        assert_cases(&[
332            (
333                "decisions before 2026-08-01",
334                "decisions",
335                None,
336                Some(1_785_542_400_000),
337                "before 2026-08-01",
338            ),
339            (
340                "decisions until 2026-08",
341                "decisions",
342                None,
343                Some(1_785_542_400_000),
344                "until 2026-08",
345            ),
346            (
347                "hnsw work since 2026-01-01",
348                "hnsw work",
349                Some(1_767_225_600_000),
350                None,
351                "since 2026-01-01",
352            ),
353            (
354                "hnsw work after 2026",
355                "hnsw work",
356                Some(1_767_225_600_000),
357                None,
358                "after 2026",
359            ),
360            (
361                "releases between 2026-01-01 and 2026-03-01",
362                "releases",
363                Some(1_767_225_600_000),
364                Some(1_772_409_600_000),
365                "between 2026-01-01 and 2026-03-01",
366            ),
367            (
368                "ci failures in 2026-08",
369                "ci failures",
370                Some(1_785_542_400_000),
371                Some(1_788_220_800_000),
372                "in 2026-08",
373            ),
374            (
375                "standup on 2026-08-01",
376                "standup",
377                Some(1_785_542_400_000),
378                Some(1_785_628_800_000),
379                "on 2026-08-01",
380            ),
381        ]);
382    }
383
384    #[test]
385    fn relative_forms_anchor_on_the_injected_now() {
386        assert_cases(&[
387            (
388                "what shipped yesterday",
389                "what shipped",
390                Some(TODAY - DAY),
391                Some(TODAY),
392                "yesterday",
393            ),
394            (
395                "standup notes today",
396                "standup notes",
397                Some(TODAY),
398                Some(TODAY + DAY),
399                "today",
400            ),
401            (
402                "bugs last week",
403                "bugs",
404                Some(TODAY - 7 * DAY),
405                None,
406                "last week",
407            ),
408            // Case-insensitive match; matched_phrase keeps original casing.
409            (
410                "merges Last Month",
411                "merges",
412                Some(TODAY - 30 * DAY),
413                None,
414                "Last Month",
415            ),
416            (
417                "deploys last 3 days",
418                "deploys",
419                Some(TODAY - 3 * DAY),
420                None,
421                "last 3 days",
422            ),
423        ]);
424    }
425
426    #[test]
427    fn unparseable_or_pure_temporal_queries_return_none() {
428        for query in [
429            "before the v8 migration",                    // no parseable date/anchor
430            "last week",                                  // pure temporal → empty residual
431            "yesterday",                                  // pure temporal
432            "port 8080 config",                           // 4 digits, not a 1970–2099 year
433            "releases between 2026-03-01 and 2026-01-01", // inverted range
434            "notes before 2026-13-01",                    // calendar-invalid month
435            "kind-aware recency prior",                   // nothing temporal at all
436        ] {
437            assert_eq!(parse_temporal_query(query, NOW_MS), None, "for {query:?}");
438        }
439    }
440
441    #[test]
442    fn residual_strips_the_phrase_without_doubling_spaces() {
443        let got = parse("topodb decisions before 2026-08-01 about hnsw");
444        assert_eq!(got.residual_query, "topodb decisions about hnsw");
445        assert_eq!(got.matched_phrase, "before 2026-08-01");
446    }
447
448    #[test]
449    fn deterministic_and_pure_under_a_shifted_now() {
450        // Relative bounds shift by exactly the now-delta; absolute bounds
451        // ignore `now` entirely. Nothing here may read a clock.
452        let base = parse_temporal_query("bugs last week", NOW_MS).unwrap();
453        let shifted = parse_temporal_query("bugs last week", NOW_MS + 3 * DAY).unwrap();
454        assert_eq!(shifted.after_ms, base.after_ms.map(|a| a + 3 * DAY));
455        let abs = parse_temporal_query("bugs since 2026-01-01", NOW_MS).unwrap();
456        let abs2 = parse_temporal_query("bugs since 2026-01-01", NOW_MS + 3 * DAY).unwrap();
457        assert_eq!(abs, abs2);
458    }
459
460    #[test]
461    fn parse_iso_instant_resolves_start_of_period() {
462        assert_eq!(parse_iso_instant("2026-08-01"), Some(1_785_542_400_000));
463        assert_eq!(parse_iso_instant("2026-08"), Some(1_785_542_400_000));
464        assert_eq!(parse_iso_instant("2026"), Some(1_767_225_600_000));
465        for bad in ["not-a-date", "08/01/2026", "", "2026-13-01", "8080"] {
466            assert_eq!(parse_iso_instant(bad), None, "for {bad:?}");
467        }
468    }
469
470    #[test]
471    fn parse_iso_instant_accepts_utc_datetimes() {
472        // Midnight datetime == the bare date.
473        assert_eq!(
474            parse_iso_instant("2026-08-01T00:00:00Z"),
475            parse_iso_instant("2026-08-01"),
476        );
477        // 15:30:00 = 55_800_000 ms into the day; Z optional; seconds optional.
478        assert_eq!(
479            parse_iso_instant("2026-08-01T15:30:00Z"),
480            Some(1_785_542_400_000 + 55_800_000),
481        );
482        assert_eq!(
483            parse_iso_instant("2026-08-01T15:30"),
484            Some(1_785_542_400_000 + 55_800_000),
485        );
486        // Non-UTC offsets, fractional seconds, out-of-range fields, and a
487        // time without a full date are all rejected, not silently shifted.
488        for bad in [
489            "2026-08-01T15:30:00+02:00",
490            "2026-08-01T15:30:00.5Z",
491            "2026-08-01T24:00",
492            "2026-08-01T15:61",
493            "2026-08T15:30",
494        ] {
495            assert_eq!(parse_iso_instant(bad), None, "for {bad:?}");
496        }
497    }
498
499    #[test]
500    fn unanalyzable_residual_passes_through_unrewritten() {
501        // "!!!" survives phrase-stripping as the residual but contains no
502        // tokenizable term — the engine would reject it. The rewriter must
503        // step aside so the original query still searches (the date words
504        // themselves are searchable terms).
505        assert_eq!(parse_temporal_query("!!! since 2026-01-01", NOW_MS), None);
506    }
507
508    #[test]
509    fn bare_year_requires_preposition() {
510        // Bare years without prepositions are rejected to avoid false matches
511        // in prose ("the 2026 roadmap" ≠ created in 2026).
512        assert_eq!(
513            parse_temporal_query("the 2026 roadmap", NOW_MS),
514            None,
515            "bare year without preposition should not match"
516        );
517
518        // Bare years WITH prepositions match correctly.
519        // 2026-01-01 = 1_767_225_600_000, 2027-01-01 = 1_798_761_600_000.
520        assert_cases(&[
521            (
522                "decisions in 2026",
523                "decisions",
524                Some(1_767_225_600_000),
525                Some(1_798_761_600_000),
526                "in 2026",
527            ),
528            (
529                "shipped during 2026",
530                "shipped",
531                Some(1_767_225_600_000),
532                Some(1_798_761_600_000),
533                "during 2026",
534            ),
535        ]);
536
537        // Bare months and dates still match without prepositions.
538        assert_cases(&[
539            (
540                "incidents 2026-08",
541                "incidents",
542                Some(1_785_542_400_000),
543                Some(1_788_220_800_000),
544                "2026-08",
545            ),
546            (
547                "notes 2026-08-01",
548                "notes",
549                Some(1_785_542_400_000),
550                Some(1_785_628_800_000),
551                "2026-08-01",
552            ),
553        ]);
554    }
555}