1use kimetsu_core::KimetsuResult;
30use rusqlite::Connection;
31
32use crate::context::ContextCapsule;
33
34pub 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
46pub fn as_of_predicate() -> &'static str {
48 AS_OF_PREDICATE
49}
50
51#[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 pub retired_at: Option<String>,
63 pub retired_reason: Option<String>,
64}
65
66pub 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
77pub 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 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 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#[derive(Debug, Clone)]
189pub struct BeliefDelta {
190 pub learned: Vec<AsOfMemory>,
192 pub retired: Vec<AsOfMemory>,
194}
195
196pub 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 #[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 #[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 #[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 #[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}