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;