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//! ## The shape here
25//!
26//! Kimetsu is already most of the way there without a schema change, because
27//! **nothing is ever destroyed**. Supersession stamps `superseded_by`,
28//! invalidation stamps `invalidated_at`, and automatic contradiction resolution
29//! stamps the loser's `valid_to` — every one of them a tombstone with a
30//! timestamp, not a delete. The rows to answer an as-of query are all present;
31//! there was simply no query that read them that way.
32//!
33//! So [`as_of_predicate`] is a WHERE clause rather than a migration:
34//!
35//! ```text
36//! created_at      <= T                    -- the brain knew it by then
37//! (invalidated_at IS NULL OR > T)         -- and had not retracted it
38//! (valid_from     IS NULL OR <= T)        -- and it had taken effect
39//! (valid_to       IS NULL OR  > T)        -- and had not expired
40//! ```
41//!
42//! Superseded memories are deliberately *included*: a memory merged into a
43//! survivor last week was a live belief the week before, and excluding it would
44//! misreport what the brain knew.
45
46use kimetsu_core::KimetsuResult;
47use rusqlite::Connection;
48
49use crate::context::ContextCapsule;
50
51/// SQL predicate selecting memories the brain believed at `?1`.
52///
53/// Takes the as-of timestamp as a single bound parameter, repeated — callers
54/// bind it once per placeholder. Written as a fragment rather than a whole
55/// query so the several candidate paths can share exactly one definition of
56/// "believed at T"; two subtly different versions of this clause would be a
57/// bug nobody would ever notice.
58pub const AS_OF_PREDICATE: &str = "created_at <= ?1 \
59     AND (invalidated_at IS NULL OR invalidated_at > ?1) \
60     AND (valid_from IS NULL OR valid_from <= ?1) \
61     AND (valid_to IS NULL OR valid_to > ?1)";
62
63/// Human-readable form of [`AS_OF_PREDICATE`], for `--explain` output and docs.
64pub fn as_of_predicate() -> &'static str {
65    AS_OF_PREDICATE
66}
67
68/// One memory as the brain held it at the as-of time.
69#[derive(Debug, Clone)]
70pub struct AsOfMemory {
71    pub memory_id: String,
72    pub scope: String,
73    pub kind: String,
74    pub text: String,
75    pub created_at: String,
76    /// Present when this memory has *since* been retired — the as-of view
77    /// shows it as live, and this says what became of it. The whole point of
78    /// the query is usually to see a belief that is no longer current.
79    pub retired_at: Option<String>,
80    pub retired_reason: Option<String>,
81}
82
83/// Every memory the brain believed at `as_of` (RFC 3339), newest first.
84///
85/// `limit` of 0 means no limit.
86pub fn memories_as_of(
87    conn: &Connection,
88    as_of: &str,
89    limit: u32,
90) -> KimetsuResult<Vec<AsOfMemory>> {
91    let sql = format!(
92        "SELECT memory_id, scope, kind, text, created_at,
93                invalidated_at, invalidated_reason, valid_to, superseded_by
94         FROM memories
95         WHERE {AS_OF_PREDICATE}
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![as_of], |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        })
178        .collect()
179}
180
181/// How the corpus changed between two points in time.
182#[derive(Debug, Clone)]
183pub struct BeliefDelta {
184    /// Believed at `to` but not at `from`.
185    pub learned: Vec<AsOfMemory>,
186    /// Believed at `from` but not at `to`.
187    pub retired: Vec<AsOfMemory>,
188}
189
190/// What the brain learned and retired between `from` and `to`.
191///
192/// The reason an as-of query is usually worth running: not "what did it know"
193/// in the abstract, but "what changed around the time this went wrong".
194pub fn belief_delta(conn: &Connection, from: &str, to: &str) -> KimetsuResult<BeliefDelta> {
195    use std::collections::HashSet;
196
197    let before = memories_as_of(conn, from, 0)?;
198    let after = memories_as_of(conn, to, 0)?;
199    let before_ids: HashSet<&str> = before.iter().map(|m| m.memory_id.as_str()).collect();
200    let after_ids: HashSet<&str> = after.iter().map(|m| m.memory_id.as_str()).collect();
201
202    Ok(BeliefDelta {
203        learned: after
204            .iter()
205            .filter(|m| !before_ids.contains(m.memory_id.as_str()))
206            .cloned()
207            .collect(),
208        retired: before
209            .iter()
210            .filter(|m| !after_ids.contains(m.memory_id.as_str()))
211            .cloned()
212            .collect(),
213    })
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    fn conn() -> Connection {
221        let conn = Connection::open_in_memory().expect("open");
222        crate::schema::initialize(&conn).expect("schema");
223        conn
224    }
225
226    #[allow(clippy::too_many_arguments)]
227    fn insert(
228        conn: &Connection,
229        id: &str,
230        text: &str,
231        created_at: &str,
232        invalidated_at: Option<&str>,
233        valid_from: Option<&str>,
234        valid_to: Option<&str>,
235        superseded_by: Option<&str>,
236    ) {
237        conn.execute(
238            "INSERT INTO memories
239             (memory_id, scope, kind, text, normalized_text, confidence,
240              provenance_snapshot_json, created_at, invalidated_at,
241              valid_from, valid_to, superseded_by)
242             VALUES (?1, 'project', 'fact', ?2, ?2, 0.9, '{}', ?3, ?4, ?5, ?6, ?7)",
243            rusqlite::params![
244                id,
245                text,
246                created_at,
247                invalidated_at,
248                valid_from,
249                valid_to,
250                superseded_by
251            ],
252        )
253        .expect("insert");
254    }
255
256    fn ids(memories: &[AsOfMemory]) -> Vec<&str> {
257        let mut v: Vec<&str> = memories.iter().map(|m| m.memory_id.as_str()).collect();
258        v.sort_unstable();
259        v
260    }
261
262    /// A memory the brain had not written yet was not believed.
263    #[test]
264    fn a_memory_written_later_is_not_in_the_past_view() {
265        let c = conn();
266        insert(
267            &c,
268            "early",
269            "a",
270            "2026-01-01T00:00:00Z",
271            None,
272            None,
273            None,
274            None,
275        );
276        insert(
277            &c,
278            "late",
279            "b",
280            "2026-06-01T00:00:00Z",
281            None,
282            None,
283            None,
284            None,
285        );
286
287        assert_eq!(
288            ids(&memories_as_of(&c, "2026-03-01T00:00:00Z", 0).unwrap()),
289            vec!["early"]
290        );
291        assert_eq!(
292            ids(&memories_as_of(&c, "2026-09-01T00:00:00Z", 0).unwrap()),
293            vec!["early", "late"]
294        );
295    }
296
297    /// The question this exists to answer: a belief that has since been
298    /// retracted must still show up in a view from before the retraction —
299    /// otherwise you cannot tell whether the agent had the information.
300    #[test]
301    fn a_retracted_memory_is_still_visible_before_its_retraction() {
302        let c = conn();
303        insert(
304            &c,
305            "retracted",
306            "the schema is v10",
307            "2026-01-01T00:00:00Z",
308            Some("2026-05-01T00:00:00Z"),
309            None,
310            None,
311            None,
312        );
313
314        let before = memories_as_of(&c, "2026-03-01T00:00:00Z", 0).unwrap();
315        assert_eq!(ids(&before), vec!["retracted"], "believed at the time");
316        assert_eq!(
317            before[0].retired_at.as_deref(),
318            Some("2026-05-01T00:00:00Z"),
319            "and the view says what became of it"
320        );
321
322        let after = memories_as_of(&c, "2026-07-01T00:00:00Z", 0).unwrap();
323        assert!(after.is_empty(), "no longer believed: {:?}", ids(&after));
324    }
325
326    /// Valid time and transaction time are independent: a fact recorded in
327    /// January but only true from March was not believed in February.
328    #[test]
329    fn valid_time_is_independent_of_when_it_was_recorded() {
330        let c = conn();
331        insert(
332            &c,
333            "future-effective",
334            "the new API lands in March",
335            "2026-01-01T00:00:00Z",
336            None,
337            Some("2026-03-01T00:00:00Z"),
338            None,
339            None,
340        );
341        assert!(
342            memories_as_of(&c, "2026-02-01T00:00:00Z", 0)
343                .unwrap()
344                .is_empty(),
345            "recorded, but not yet in effect"
346        );
347        assert_eq!(
348            ids(&memories_as_of(&c, "2026-04-01T00:00:00Z", 0).unwrap()),
349            vec!["future-effective"]
350        );
351    }
352
353    #[test]
354    fn an_expired_memory_drops_out_after_its_valid_to() {
355        let c = conn();
356        insert(
357            &c,
358            "expired",
359            "we are on rust 1.85",
360            "2026-01-01T00:00:00Z",
361            None,
362            None,
363            Some("2026-04-01T00:00:00Z"),
364            None,
365        );
366        assert_eq!(
367            ids(&memories_as_of(&c, "2026-02-01T00:00:00Z", 0).unwrap()),
368            vec!["expired"]
369        );
370        assert!(
371            memories_as_of(&c, "2026-05-01T00:00:00Z", 0)
372                .unwrap()
373                .is_empty()
374        );
375    }
376
377    /// A memory merged into a survivor last week was a live belief the week
378    /// before. Excluding it would misreport what the brain knew.
379    #[test]
380    fn a_superseded_memory_still_counts_as_a_past_belief() {
381        let c = conn();
382        insert(
383            &c,
384            "member",
385            "checkpoint the wal",
386            "2026-01-01T00:00:00Z",
387            None,
388            None,
389            None,
390            Some("survivor"),
391        );
392        let view = memories_as_of(&c, "2026-03-01T00:00:00Z", 0).unwrap();
393        assert_eq!(ids(&view), vec!["member"]);
394        assert_eq!(
395            view[0].retired_reason.as_deref(),
396            Some("merged into survivor"),
397            "and the view explains where it went"
398        );
399    }
400
401    #[test]
402    fn belief_delta_reports_what_was_learned_and_retired() {
403        let c = conn();
404        insert(
405            &c,
406            "kept",
407            "a",
408            "2026-01-01T00:00:00Z",
409            None,
410            None,
411            None,
412            None,
413        );
414        insert(
415            &c,
416            "dropped",
417            "b",
418            "2026-01-01T00:00:00Z",
419            Some("2026-04-01T00:00:00Z"),
420            None,
421            None,
422            None,
423        );
424        insert(
425            &c,
426            "added",
427            "c",
428            "2026-03-01T00:00:00Z",
429            None,
430            None,
431            None,
432            None,
433        );
434
435        let delta = belief_delta(&c, "2026-02-01T00:00:00Z", "2026-06-01T00:00:00Z").unwrap();
436        assert_eq!(ids(&delta.learned), vec!["added"]);
437        assert_eq!(ids(&delta.retired), vec!["dropped"]);
438    }
439
440    #[test]
441    fn the_limit_is_respected_and_zero_means_all() {
442        let c = conn();
443        for i in 0..5 {
444            insert(
445                &c,
446                &format!("m{i}"),
447                "x",
448                &format!("2026-01-0{}T00:00:00Z", i + 1),
449                None,
450                None,
451                None,
452                None,
453            );
454        }
455        assert_eq!(
456            memories_as_of(&c, "2026-09-01T00:00:00Z", 0).unwrap().len(),
457            5
458        );
459        assert_eq!(
460            memories_as_of(&c, "2026-09-01T00:00:00Z", 2).unwrap().len(),
461            2
462        );
463    }
464
465    #[test]
466    fn as_of_capsules_render_the_scope_and_kind_prefix() {
467        let c = conn();
468        insert(
469            &c,
470            "m",
471            "checkpoint the wal",
472            "2026-01-01T00:00:00Z",
473            None,
474            None,
475            None,
476            None,
477        );
478        let capsules = as_of_capsules(&memories_as_of(&c, "2026-02-01T00:00:00Z", 0).unwrap());
479        assert_eq!(capsules.len(), 1);
480        assert!(capsules[0].summary.starts_with("project:fact - "));
481        assert_eq!(capsules[0].expansion_handle, "memory:m");
482    }
483}