Skip to main content

velesdb_memory/
dated_context.rs

1//! Dated context: turn recalled facts into a chronological, date-prefixed
2//! timeline with a "now" anchor — the representation measured to lift temporal
3//! question answering (the `examples/locomo` temporal ablation: +33.6pp,
4//! McNemar p=1.8e-28). This ships that representation as product behavior so a
5//! caller reproduces it through the installed API instead of re-implementing
6//! the formatting in a prompt.
7//!
8//! The date lives in caller-supplied metadata under a field the caller names
9//! (e.g. `ts`, `occurred_at`), holding a `YYYYMMDD` integer — the same key
10//! shape `recall_where` filters on. A fact whose named field is missing or not
11//! a valid `YYYYMMDD` date is treated as undated: it still appears, just without
12//! a date prefix and after the dated timeline, so an unlabeled fact never
13//! invents a misleading date.
14//!
15//! Only the *formatting* ships here — not the LLM reasoning prompt the harness
16//! wraps around it. Presenting retrieved facts is a memory-store concern;
17//! prompt engineering is the caller's.
18
19use crate::model::Recollection;
20
21/// A chronological, date-prefixed rendering of recalled facts.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct DatedContext {
24    /// One line per fact: `- [YYYY-MM-DD] content` for a dated fact, `- content`
25    /// for an undated one. Dated facts come first in ascending date order; any
26    /// undated facts follow in their original (relevance) order.
27    pub timeline: String,
28    /// The most recent date across the facts (`YYYY-MM-DD`), the natural "now"
29    /// anchor for temporal reasoning. `None` when no fact carries a valid date.
30    pub now: Option<String>,
31}
32
33/// Render `facts` as a [`DatedContext`], reading each fact's date from the
34/// `date_field` metadata key (a `YYYYMMDD` integer).
35///
36/// Facts are split into dated (sorted oldest-first) and undated (kept in the
37/// order given, i.e. by relevance), then rendered one per line. `now` is the
38/// latest date seen. An empty `facts` yields an empty timeline and `now: None`.
39#[must_use]
40pub fn format_dated_context(facts: &[Recollection], date_field: &str) -> DatedContext {
41    // (sort key, pre-formatted `YYYY-MM-DD`, content) for dated facts; content
42    // only for undated ones. The date is formatted once here, so nothing
43    // downstream re-parses it.
44    let mut dated: Vec<(i64, String, &str)> = Vec::new();
45    let mut undated: Vec<&str> = Vec::new();
46    for fact in facts {
47        match fact_date(fact, date_field) {
48            Some((key, formatted)) => dated.push((key, formatted, &fact.content)),
49            None => undated.push(&fact.content),
50        }
51    }
52    // Ascending chronological order; a stable sort keeps same-date facts in
53    // their original relevance order.
54    dated.sort_by_key(|(key, _, _)| *key);
55    let now = dated.last().map(|(_, formatted, _)| formatted.clone());
56
57    let lines = dated
58        .iter()
59        .map(|(_, date, content)| format!("- [{date}] {content}"))
60        .chain(undated.iter().map(|content| format!("- {content}")))
61        .collect::<Vec<_>>()
62        .join("\n");
63
64    DatedContext {
65        timeline: lines,
66        now,
67    }
68}
69
70/// A fact's `(sort key, formatted "YYYY-MM-DD")` from its `date_field`
71/// metadata, or `None` when the field is absent, non-integer, or not a valid
72/// calendar date — so a plain counter (or an impossible date like `20260231`)
73/// living under the date field is treated as undated, not rendered as a
74/// nonsense timeline anchor. Formats the date here so the caller never parses
75/// the integer twice.
76fn fact_date(fact: &Recollection, date_field: &str) -> Option<(i64, String)> {
77    let raw = fact.metadata.as_ref()?.get(date_field)?.as_i64()?;
78    Some((raw, fmt_date(raw)?))
79}
80
81/// Render a `YYYYMMDD` integer as `YYYY-MM-DD`, or `None` when it is not a valid
82/// calendar date.
83fn fmt_date(ts: i64) -> Option<String> {
84    let (year, month, day) = decompose_ymd(ts)?;
85    Some(format!("{year:04}-{month:02}-{day:02}"))
86}
87
88/// Split a `YYYYMMDD` integer into `(year, month, day)`, or `None` when it is
89/// `<= 0`, the month is out of range, or the day exceeds that month's real
90/// length (leap years included) — the single validity rule for the date
91/// convention, stricter than the harness's `1..=31` so no impossible date ever
92/// reaches the timeline.
93fn decompose_ymd(ts: i64) -> Option<(i64, i64, i64)> {
94    if ts <= 0 {
95        return None;
96    }
97    let (year, month, day) = (ts / 10_000, (ts / 100) % 100, ts % 100);
98    if !(1..=12).contains(&month) {
99        return None;
100    }
101    (1..=days_in_month(year, month))
102        .contains(&day)
103        .then_some((year, month, day))
104}
105
106/// Days in `month` (1..=12) of `year` in the proleptic Gregorian calendar
107/// (February is 29 in a leap year). Only ever called with a validated month.
108fn days_in_month(year: i64, month: i64) -> i64 {
109    match month {
110        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
111        4 | 6 | 9 | 11 => 30,
112        2 if is_leap_year(year) => 29,
113        2 => 28,
114        _ => 0,
115    }
116}
117
118/// Whether `year` is a leap year (Gregorian rule).
119fn is_leap_year(year: i64) -> bool {
120    year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
121}
122
123#[cfg(test)]
124#[path = "dated_context_tests.rs"]
125mod tests;