Skip to main content

kimetsu_brain/
bitemporal.rs

1//! v2.6: asking the brain what it believed at a point in time.
2//!
3//! A memory has two independent time axes, and Kimetsu has only ever had one
4//! of them working:
5//!
6//! * **Valid time** — when the fact was true in the world. `valid_from` /
7//!   `valid_to`, added in v2.5 for temporal validity, and what default
8//!   retrieval filters on: a memory whose `valid_to` is in the past is
9//!   excluded.
10//! * **Transaction time** — when the brain *learned* it. `created_at` for the
11//!   write, and `invalidated_at` / the loser's stamped `valid_to` for the
12//!   retraction.
13//!
14//! With only the first, you can ask "what is true now?" and (via
15//! [`crate::context::search_memories_including_expired`]) "what was ever
16//! recorded?". You cannot ask **"what did the brain believe on the 3rd?"**,
17//! which is the question that matters when a past decision looks wrong and you
18//! need to know whether the agent had the information at the time.
19//!
20//! That is the query Zep's bitemporal graph is built around, and the reason a
21//! contradicting fact there invalidates rather than overwrites: history stays
22//! answerable.
23//!
24//! Corrections retain their text and kind in `memory_revisions`. The original
25//! single-time API delegates to [`memories_at`], which separates effective and
26//! known time. Retirement and validity still use the memory's tombstones;
27//! temporal metadata edits are not yet independently revisioned.
28
29use kimetsu_core::KimetsuResult;
30use rusqlite::Connection;
31
32use crate::context::ContextCapsule;
33
34/// SQL predicate selecting memories the brain believed at `?1`.
35///
36/// Takes the as-of timestamp as a single bound parameter, repeated — callers
37/// bind it once per placeholder. Written as a fragment rather than a whole
38/// query so the several candidate paths can share exactly one definition of
39/// "believed at T"; two subtly different versions of this clause would be a
40/// bug nobody would ever notice.
41pub const AS_OF_PREDICATE: &str = "julianday(created_at) <= julianday(?1) \
42     AND (invalidated_at IS NULL OR julianday(invalidated_at) > julianday(?1)) \
43     AND (valid_from IS NULL OR julianday(valid_from) <= julianday(?1)) \
44     AND (valid_to IS NULL OR julianday(valid_to) > julianday(?1))";
45
46/// Human-readable form of [`AS_OF_PREDICATE`], for `--explain` output and docs.
47pub fn as_of_predicate() -> &'static str {
48    AS_OF_PREDICATE
49}
50
51/// One memory as the brain held it at the as-of time.
52#[derive(Debug, Clone)]
53pub struct AsOfMemory {
54    pub memory_id: String,
55    pub scope: String,
56    pub kind: String,
57    pub text: String,
58    pub created_at: String,
59    /// Present when this memory has *since* been retired — the as-of view
60    /// shows it as live, and this says what became of it. The whole point of
61    /// the query is usually to see a belief that is no longer current.
62    pub retired_at: Option<String>,
63    pub retired_reason: Option<String>,
64}
65
66/// Every memory the brain believed at `as_of` (RFC 3339), newest first.
67///
68/// `limit` of 0 means no limit.
69pub fn memories_as_of(
70    conn: &Connection,
71    as_of: &str,
72    limit: u32,
73) -> KimetsuResult<Vec<AsOfMemory>> {
74    memories_at(conn, as_of, as_of, limit)
75}
76
77/// Query independently when a claim was effective and when it was known.
78/// Corrections default effective time to their recording time; imported events
79/// may supply an explicit RFC3339 `effective_at` for late-arriving corrections.
80pub fn memories_at(
81    conn: &Connection,
82    valid_at: &str,
83    known_at: &str,
84    limit: u32,
85) -> KimetsuResult<Vec<AsOfMemory>> {
86    let sql = format!(
87        "SELECT memory_id, scope,
88                COALESCE((SELECT kind FROM memory_revisions r WHERE r.memory_id=m.memory_id AND julianday(r.known_at)<=julianday(?2) AND julianday(r.effective_at)<=julianday(?1) ORDER BY julianday(r.known_at) DESC,revision_id DESC LIMIT 1),kind),
89                COALESCE((SELECT text FROM memory_revisions r WHERE r.memory_id=m.memory_id AND julianday(r.known_at)<=julianday(?2) AND julianday(r.effective_at)<=julianday(?1) ORDER BY julianday(r.known_at) DESC,revision_id DESC LIMIT 1),text), created_at,
90                invalidated_at, invalidated_reason, valid_to, superseded_by
91         FROM memories m
92         WHERE julianday(created_at)<=julianday(?2)
93           AND (invalidated_at IS NULL OR julianday(invalidated_at)>julianday(?2))
94           AND (valid_from IS NULL OR julianday(valid_from)<=julianday(?1))
95           AND (valid_to IS NULL OR julianday(valid_to)>julianday(?1))
96         ORDER BY created_at DESC
97         {}",
98        if limit == 0 {
99            String::new()
100        } else {
101            format!("LIMIT {limit}")
102        }
103    );
104    let mut stmt = conn.prepare(&sql)?;
105    let rows = stmt
106        .query_map(rusqlite::params![valid_at, known_at], |row| {
107            Ok((
108                row.get::<_, String>(0)?,
109                row.get::<_, String>(1)?,
110                row.get::<_, String>(2)?,
111                row.get::<_, String>(3)?,
112                row.get::<_, String>(4)?,
113                row.get::<_, Option<String>>(5)?,
114                row.get::<_, Option<String>>(6)?,
115                row.get::<_, Option<String>>(7)?,
116                row.get::<_, Option<String>>(8)?,
117            ))
118        })?
119        .collect::<Result<Vec<_>, _>>()?;
120
121    Ok(rows
122        .into_iter()
123        .map(
124            |(
125                memory_id,
126                scope,
127                kind,
128                text,
129                created_at,
130                invalidated_at,
131                invalidated_reason,
132                valid_to,
133                superseded_by,
134            )| {
135                // What became of it, in the order the brain would have applied
136                // it: an explicit invalidation, else an expiry, else a merge.
137                let (retired_at, retired_reason) = match (invalidated_at, valid_to, superseded_by) {
138                    (Some(at), _, _) => (
139                        Some(at),
140                        Some(invalidated_reason.unwrap_or_else(|| "invalidated".to_string())),
141                    ),
142                    (None, Some(until), _) => (Some(until), Some("expired".to_string())),
143                    (None, None, Some(survivor)) => (None, Some(format!("merged into {survivor}"))),
144                    (None, None, None) => (None, None),
145                };
146                AsOfMemory {
147                    memory_id,
148                    scope,
149                    kind,
150                    text,
151                    created_at,
152                    retired_at,
153                    retired_reason,
154                }
155            },
156        )
157        .collect())
158}
159
160/// Render as-of memories as context capsules, so an as-of view can be handed to
161/// a reader the same way a live bundle is.
162pub fn as_of_capsules(memories: &[AsOfMemory]) -> Vec<ContextCapsule> {
163    memories
164        .iter()
165        .map(|m| ContextCapsule {
166            id: String::new(),
167            kind: "memory".to_string(),
168            summary: format!("{}:{} - {}", m.scope, m.kind, m.text),
169            token_estimate: (m.text.len() / 4) as u32 + 8,
170            expansion_handle: format!("memory:{}", m.memory_id),
171            provenance: Vec::new(),
172            confidence: 1.0,
173            freshness: 0.0,
174            relevance: 0.0,
175            scope_weight: 0.0,
176            score: 0.0,
177            superseded_hint: false,
178            rerank_policy_tier: 0,
179            claim_revision: None,
180            facts: vec![],
181            rerank_usefulness: None,
182            rerank_trust: None,
183        })
184        .collect()
185}
186
187/// How the corpus changed between two points in time.
188#[derive(Debug, Clone)]
189pub struct BeliefDelta {
190    /// Believed at `to` but not at `from`.
191    pub learned: Vec<AsOfMemory>,
192    /// Believed at `from` but not at `to`.
193    pub retired: Vec<AsOfMemory>,
194}
195
196/// What the brain learned and retired between `from` and `to`.
197///
198/// The reason an as-of query is usually worth running: not "what did it know"
199/// in the abstract, but "what changed around the time this went wrong".
200pub fn belief_delta(conn: &Connection, from: &str, to: &str) -> KimetsuResult<BeliefDelta> {
201    use std::collections::HashSet;
202
203    let before = memories_as_of(conn, from, 0)?;
204    let after = memories_as_of(conn, to, 0)?;
205    let before_ids: HashSet<_> = before
206        .iter()
207        .map(|m| (&m.memory_id, &m.text, &m.kind))
208        .collect();
209    let after_ids: HashSet<_> = after
210        .iter()
211        .map(|m| (&m.memory_id, &m.text, &m.kind))
212        .collect();
213
214    Ok(BeliefDelta {
215        learned: after
216            .iter()
217            .filter(|m| !before_ids.contains(&(&m.memory_id, &m.text, &m.kind)))
218            .cloned()
219            .collect(),
220        retired: before
221            .iter()
222            .filter(|m| !after_ids.contains(&(&m.memory_id, &m.text, &m.kind)))
223            .cloned()
224            .collect(),
225    })
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    fn conn() -> Connection {
233        let conn = Connection::open_in_memory().expect("open");
234        crate::schema::initialize(&conn).expect("schema");
235        conn
236    }
237
238    #[allow(clippy::too_many_arguments)]
239    fn insert(
240        conn: &Connection,
241        id: &str,
242        text: &str,
243        created_at: &str,
244        invalidated_at: Option<&str>,
245        valid_from: Option<&str>,
246        valid_to: Option<&str>,
247        superseded_by: Option<&str>,
248    ) {
249        conn.execute(
250            "INSERT INTO memories
251             (memory_id, scope, kind, text, normalized_text, confidence,
252              provenance_snapshot_json, created_at, invalidated_at,
253              valid_from, valid_to, superseded_by)
254             VALUES (?1, 'project', 'fact', ?2, ?2, 0.9, '{}', ?3, ?4, ?5, ?6, ?7)",
255            rusqlite::params![
256                id,
257                text,
258                created_at,
259                invalidated_at,
260                valid_from,
261                valid_to,
262                superseded_by
263            ],
264        )
265        .expect("insert");
266    }
267
268    fn ids(memories: &[AsOfMemory]) -> Vec<&str> {
269        let mut v: Vec<&str> = memories.iter().map(|m| m.memory_id.as_str()).collect();
270        v.sort_unstable();
271        v
272    }
273
274    /// A memory the brain had not written yet was not believed.
275    #[test]
276    fn a_memory_written_later_is_not_in_the_past_view() {
277        let c = conn();
278        insert(
279            &c,
280            "early",
281            "a",
282            "2026-01-01T00:00:00Z",
283            None,
284            None,
285            None,
286            None,
287        );
288        insert(
289            &c,
290            "late",
291            "b",
292            "2026-06-01T00:00:00Z",
293            None,
294            None,
295            None,
296            None,
297        );
298
299        assert_eq!(
300            ids(&memories_as_of(&c, "2026-03-01T00:00:00Z", 0).unwrap()),
301            vec!["early"]
302        );
303        assert_eq!(
304            ids(&memories_as_of(&c, "2026-09-01T00:00:00Z", 0).unwrap()),
305            vec!["early", "late"]
306        );
307    }
308
309    /// The question this exists to answer: a belief that has since been
310    /// retracted must still show up in a view from before the retraction —
311    /// otherwise you cannot tell whether the agent had the information.
312    #[test]
313    fn a_retracted_memory_is_still_visible_before_its_retraction() {
314        let c = conn();
315        insert(
316            &c,
317            "retracted",
318            "the schema is v10",
319            "2026-01-01T00:00:00Z",
320            Some("2026-05-01T00:00:00Z"),
321            None,
322            None,
323            None,
324        );
325
326        let before = memories_as_of(&c, "2026-03-01T00:00:00Z", 0).unwrap();
327        assert_eq!(ids(&before), vec!["retracted"], "believed at the time");
328        assert_eq!(
329            before[0].retired_at.as_deref(),
330            Some("2026-05-01T00:00:00Z"),
331            "and the view says what became of it"
332        );
333
334        let after = memories_as_of(&c, "2026-07-01T00:00:00Z", 0).unwrap();
335        assert!(after.is_empty(), "no longer believed: {:?}", ids(&after));
336    }
337
338    /// Valid time and transaction time are independent: a fact recorded in
339    /// January but only true from March was not believed in February.
340    #[test]
341    fn valid_time_is_independent_of_when_it_was_recorded() {
342        let c = conn();
343        insert(
344            &c,
345            "future-effective",
346            "the new API lands in March",
347            "2026-01-01T00:00:00Z",
348            None,
349            Some("2026-03-01T00:00:00Z"),
350            None,
351            None,
352        );
353        assert!(
354            memories_as_of(&c, "2026-02-01T00:00:00Z", 0)
355                .unwrap()
356                .is_empty(),
357            "recorded, but not yet in effect"
358        );
359        assert_eq!(
360            ids(&memories_as_of(&c, "2026-04-01T00:00:00Z", 0).unwrap()),
361            vec!["future-effective"]
362        );
363    }
364
365    #[test]
366    fn an_expired_memory_drops_out_after_its_valid_to() {
367        let c = conn();
368        insert(
369            &c,
370            "expired",
371            "we are on rust 1.85",
372            "2026-01-01T00:00:00Z",
373            None,
374            None,
375            Some("2026-04-01T00:00:00Z"),
376            None,
377        );
378        assert_eq!(
379            ids(&memories_as_of(&c, "2026-02-01T00:00:00Z", 0).unwrap()),
380            vec!["expired"]
381        );
382        assert!(
383            memories_as_of(&c, "2026-05-01T00:00:00Z", 0)
384                .unwrap()
385                .is_empty()
386        );
387    }
388
389    /// A memory merged into a survivor last week was a live belief the week
390    /// before. Excluding it would misreport what the brain knew.
391    #[test]
392    fn a_superseded_memory_still_counts_as_a_past_belief() {
393        let c = conn();
394        insert(
395            &c,
396            "member",
397            "checkpoint the wal",
398            "2026-01-01T00:00:00Z",
399            None,
400            None,
401            None,
402            Some("survivor"),
403        );
404        let view = memories_as_of(&c, "2026-03-01T00:00:00Z", 0).unwrap();
405        assert_eq!(ids(&view), vec!["member"]);
406        assert_eq!(
407            view[0].retired_reason.as_deref(),
408            Some("merged into survivor"),
409            "and the view explains where it went"
410        );
411    }
412
413    #[test]
414    fn belief_delta_reports_what_was_learned_and_retired() {
415        let c = conn();
416        insert(
417            &c,
418            "kept",
419            "a",
420            "2026-01-01T00:00:00Z",
421            None,
422            None,
423            None,
424            None,
425        );
426        insert(
427            &c,
428            "dropped",
429            "b",
430            "2026-01-01T00:00:00Z",
431            Some("2026-04-01T00:00:00Z"),
432            None,
433            None,
434            None,
435        );
436        insert(
437            &c,
438            "added",
439            "c",
440            "2026-03-01T00:00:00Z",
441            None,
442            None,
443            None,
444            None,
445        );
446
447        let delta = belief_delta(&c, "2026-02-01T00:00:00Z", "2026-06-01T00:00:00Z").unwrap();
448        assert_eq!(ids(&delta.learned), vec!["added"]);
449        assert_eq!(ids(&delta.retired), vec!["dropped"]);
450    }
451
452    #[test]
453    fn the_limit_is_respected_and_zero_means_all() {
454        let c = conn();
455        for i in 0..5 {
456            insert(
457                &c,
458                &format!("m{i}"),
459                "x",
460                &format!("2026-01-0{}T00:00:00Z", i + 1),
461                None,
462                None,
463                None,
464                None,
465            );
466        }
467        assert_eq!(
468            memories_as_of(&c, "2026-09-01T00:00:00Z", 0).unwrap().len(),
469            5
470        );
471        assert_eq!(
472            memories_as_of(&c, "2026-09-01T00:00:00Z", 2).unwrap().len(),
473            2
474        );
475    }
476
477    #[test]
478    fn as_of_capsules_render_the_scope_and_kind_prefix() {
479        let c = conn();
480        insert(
481            &c,
482            "m",
483            "checkpoint the wal",
484            "2026-01-01T00:00:00Z",
485            None,
486            None,
487            None,
488            None,
489        );
490        let capsules = as_of_capsules(&memories_as_of(&c, "2026-02-01T00:00:00Z", 0).unwrap());
491        assert_eq!(capsules.len(), 1);
492        assert!(capsules[0].summary.starts_with("project:fact - "));
493        assert_eq!(capsules[0].expansion_handle, "memory:m");
494    }
495}