1use kimetsu_core::KimetsuResult;
47use rusqlite::Connection;
48
49use crate::context::ContextCapsule;
50
51pub 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
63pub fn as_of_predicate() -> &'static str {
65 AS_OF_PREDICATE
66}
67
68#[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 pub retired_at: Option<String>,
80 pub retired_reason: Option<String>,
81}
82
83pub 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 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
160pub 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#[derive(Debug, Clone)]
183pub struct BeliefDelta {
184 pub learned: Vec<AsOfMemory>,
186 pub retired: Vec<AsOfMemory>,
188}
189
190pub 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 #[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 #[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 #[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 #[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}