1use anyhow::{Context, Result, bail};
2use serde::{Deserialize, Serialize};
3use std::collections::{BTreeMap, BTreeSet};
4use std::path::Path;
5use tsift_core::{GraphEdge, GraphFreshness, GraphNode, GraphProjection, GraphProvenance};
6use tsift_memory::{MemoryEvent, estimate_tokens, read_memory_events};
7use tsift_sqlite::SqliteGraphStore;
8
9pub const MEMGRAPHRAG_CONTRACT_VERSION: &str = "tsift-memgraphrag-v1";
10pub const SEMANTIC_EMBEDDING_MODEL: &str = "tsift-local-hash-v1";
11
12const SEMANTIC_EMBEDDING_DIM: usize = 32;
13const DEFAULT_TRAVERSAL_MEMORY_EVENT_LIMIT: usize = 600;
14
15pub fn memory_graph_node_kinds() -> Vec<&'static str> {
16 vec![
17 "memory_session",
18 "memory_event",
19 "session",
20 "source_handle",
21 "semantic_concept",
22 "semantic_vector_handle",
23 ]
24}
25
26pub fn project_memory_events(events: &[MemoryEvent]) -> GraphProjection {
27 let mut projection = GraphProjection::default();
28 let mut sessions = BTreeSet::new();
29
30 for event in events {
31 let event_id = event.stable_id();
32 if let Some(session_id) = &event.session_id
33 && sessions.insert(session_id.clone())
34 {
35 projection.nodes.push(
36 GraphNode::new(
37 format!("memsess:{}", blake3::hash(session_id.as_bytes()).to_hex()),
38 "memory_session",
39 session_id,
40 )
41 .with_property("session_id", session_id)
42 .with_provenance(GraphProvenance::new("tsift-memory", session_id)),
43 );
44 }
45
46 let mut node = GraphNode::new(&event_id, "memory_event", event.kind.as_str())
47 .with_property("event_kind", event.kind.as_str())
48 .with_property("source_ref", &event.source_ref)
49 .with_property("token_estimate", event.token_estimate.to_string())
50 .with_provenance(GraphProvenance::new("tsift-memory", &event.source_ref));
51 if let Some(imported_from) = &event.imported_from {
52 node = node.with_property("imported_from", imported_from);
53 }
54 if let Some(imported_id) = &event.imported_id {
55 node = node.with_property("imported_id", imported_id);
56 }
57 projection.nodes.push(node);
58
59 if let Some(session_id) = &event.session_id {
60 let session_node_id =
61 format!("memsess:{}", blake3::hash(session_id.as_bytes()).to_hex());
62 projection.edges.push(
63 GraphEdge::new(session_node_id, event_id, "records_memory_event")
64 .with_provenance(GraphProvenance::new("tsift-memory", &event.source_ref)),
65 );
66 }
67 }
68
69 projection
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
73pub struct MemoryDecayConfig {
74 pub half_life_secs: f64,
75 pub lexical_weight: f64,
76 pub recency_weight: f64,
77}
78
79impl Default for MemoryDecayConfig {
80 fn default() -> Self {
81 Self {
82 half_life_secs: 7.0 * 24.0 * 3600.0,
83 lexical_weight: 0.6,
84 recency_weight: 0.4,
85 }
86 }
87}
88
89#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
90pub struct ScoredMemoryEvent {
91 pub event: MemoryEvent,
92 pub lexical_score: f64,
93 pub recency_score: f64,
94 pub score: f64,
95}
96
97fn memory_query_terms(query: &str) -> Vec<String> {
98 query
99 .split(|c: char| !c.is_alphanumeric())
100 .filter(|term| !term.is_empty())
101 .map(|term| term.to_lowercase())
102 .collect()
103}
104
105fn memory_lexical_overlap(terms: &[String], text: &str) -> f64 {
106 if terms.is_empty() {
107 return 0.0;
108 }
109 let haystack = text.to_lowercase();
110 let hits = terms
111 .iter()
112 .filter(|term| haystack.contains(term.as_str()))
113 .count();
114 hits as f64 / terms.len() as f64
115}
116
117fn memory_recency_decay(observed_at_unix: Option<i64>, now_unix: i64, half_life_secs: f64) -> f64 {
118 match observed_at_unix {
119 Some(observed) => {
120 let age = (now_unix - observed).max(0) as f64;
121 0.5f64.powf(age / half_life_secs.max(1.0))
122 }
123 None => 0.0,
124 }
125}
126
127pub fn rank_memory_events(
128 events: &[MemoryEvent],
129 query: &str,
130 now_unix: i64,
131 config: MemoryDecayConfig,
132 limit: usize,
133) -> Vec<ScoredMemoryEvent> {
134 let terms = memory_query_terms(query);
135 let mut scored: Vec<ScoredMemoryEvent> = events
136 .iter()
137 .map(|event| {
138 let lexical_score = memory_lexical_overlap(&terms, &event.text);
139 let recency_score =
140 memory_recency_decay(event.observed_at_unix, now_unix, config.half_life_secs);
141 let score =
142 config.lexical_weight * lexical_score + config.recency_weight * recency_score;
143 ScoredMemoryEvent {
144 event: event.clone(),
145 lexical_score,
146 recency_score,
147 score,
148 }
149 })
150 .collect();
151 scored.sort_by(|a, b| {
152 b.score
153 .partial_cmp(&a.score)
154 .unwrap_or(std::cmp::Ordering::Equal)
155 .then_with(|| {
156 b.recency_score
157 .partial_cmp(&a.recency_score)
158 .unwrap_or(std::cmp::Ordering::Equal)
159 })
160 });
161 scored.truncate(limit);
162 scored
163}
164
165#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
166pub struct MemoryQueryPlan {
167 pub contract_version: String,
168 pub query: String,
169 pub limit: usize,
170 pub max_tokens: usize,
171 pub estimated_query_tokens: usize,
172 pub decay: MemoryDecayConfig,
173 pub output_contract: Vec<String>,
174 pub next_commands: Vec<String>,
175}
176
177pub fn plan_memory_query(query: &str, limit: usize, max_tokens: usize) -> Result<MemoryQueryPlan> {
178 if query.trim().is_empty() {
179 bail!("memory query must not be empty");
180 }
181 Ok(MemoryQueryPlan {
182 contract_version: MEMGRAPHRAG_CONTRACT_VERSION.to_string(),
183 query: query.to_string(),
184 limit,
185 max_tokens,
186 estimated_query_tokens: estimate_tokens(query),
187 decay: MemoryDecayConfig::default(),
188 output_contract: vec![
189 "decay-weighted ranked memory_event ids (lexical + recency)".to_string(),
190 "per-event lexical_score, recency_score, and blended score".to_string(),
191 "source_ref handles for expansion".to_string(),
192 "graph node ids for neighborhood projection".to_string(),
193 "token estimates for every returned packet".to_string(),
194 ],
195 next_commands: vec![
196 "tsift memory status . --json".to_string(),
197 "tsift memory project-graph . --json".to_string(),
198 "tsift graph-db --path . --json related '<query>'".to_string(),
199 ],
200 })
201}
202
203#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
204pub struct MemoryGraphProjectReport {
205 pub events_projected: usize,
206 pub nodes_upserted: usize,
207 pub edges_upserted: usize,
208}
209
210pub fn project_memory_into_graph(
211 memory_db: &Path,
212 graph_db: &Path,
213 limit: usize,
214) -> Result<MemoryGraphProjectReport> {
215 let events = read_memory_events(memory_db, limit)?;
216 let projection = project_memory_events(&events);
217 let nodes_upserted = projection.nodes.len();
218 let edges_upserted = projection.edges.len();
219 if let Some(parent) = graph_db.parent() {
220 std::fs::create_dir_all(parent)
221 .with_context(|| format!("create graph db dir {}", parent.display()))?;
222 }
223 let mut store = SqliteGraphStore::open(graph_db)
224 .with_context(|| format!("open graph store {}", graph_db.display()))?;
225 store.upsert_projection(&projection)?;
226 Ok(MemoryGraphProjectReport {
227 events_projected: events.len(),
228 nodes_upserted,
229 edges_upserted,
230 })
231}
232
233#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
234pub struct MemoryOntologyGraphReport {
235 pub type_nodes: usize,
236 pub relations: usize,
237}
238
239pub fn derive_memory_ontology_graph(graph_db: &Path) -> Result<MemoryOntologyGraphReport> {
240 if !graph_db.exists() {
241 bail!(
242 "graph store {} does not exist; run `tsift graph-db refresh` or `tsift memory project-graph` first",
243 graph_db.display()
244 );
245 }
246 let mut store = SqliteGraphStore::open(graph_db)
247 .with_context(|| format!("open graph store {}", graph_db.display()))?;
248 let ontology = store.derive_ontology()?;
249 let type_nodes = ontology.nodes.len();
250 let relations = ontology.edges.len();
251 store.upsert_projection(&ontology)?;
252 Ok(MemoryOntologyGraphReport {
253 type_nodes,
254 relations,
255 })
256}
257
258pub fn append_tsift_memory_graph_projection_rows(
259 root: &Path,
260 nodes: &mut Vec<GraphNode>,
261 edges: &mut Vec<GraphEdge>,
262) -> Result<()> {
263 append_tsift_memory_graph_projection_rows_with_limit(
264 root,
265 nodes,
266 edges,
267 DEFAULT_TRAVERSAL_MEMORY_EVENT_LIMIT,
268 )
269}
270
271pub fn append_tsift_memory_graph_projection_rows_with_limit(
272 root: &Path,
273 nodes: &mut Vec<GraphNode>,
274 edges: &mut Vec<GraphEdge>,
275 event_limit: usize,
276) -> Result<()> {
277 let memory_db = tsift_memory::default_memory_db_path(root);
278 if !memory_db.exists() {
279 return Ok(());
280 }
281 let events = match read_memory_events(&memory_db, event_limit) {
282 Ok(events) => events,
283 Err(_) => return Ok(()),
284 };
285 append_memory_events_as_traversal_rows(root, &events, nodes, edges)
286}
287
288pub fn append_memory_events_as_traversal_rows(
289 root: &Path,
290 events: &[MemoryEvent],
291 nodes: &mut Vec<GraphNode>,
292 edges: &mut Vec<GraphEdge>,
293) -> Result<()> {
294 if events.is_empty() {
295 return Ok(());
296 }
297
298 let mut seen_sessions = BTreeSet::new();
299 let mut edge_map = BTreeMap::<(String, String, String), GraphEdge>::new();
300
301 for event in events {
302 let event_id = event.stable_id();
303 let event_key = memory_event_key(event);
304 let source_handle = stable_handle("tmemsrc", &event_key);
305 let semantic_handle = stable_handle("tmemsem", &event_key);
306 let provenance = GraphProvenance::new("tsift-memory", &event.source_ref);
307 let imported_from = event.imported_from.as_deref().unwrap_or("native");
308
309 if let Some(session_id) = &event.session_id {
310 let session_handle =
311 format!("memsess:{}", blake3::hash(session_id.as_bytes()).to_hex());
312 if seen_sessions.insert(session_id.clone()) {
313 let session_node = GraphNode::new(
314 session_handle.clone(),
315 "memory_session",
316 truncate_for_compact(session_id, 80),
317 )
318 .with_property("handle", session_handle.clone())
319 .with_property("ref_id", session_id.clone())
320 .with_property("session_id", session_id.clone())
321 .with_property("provider", "tsift-memory")
322 .with_property(
323 "expand",
324 format!(
325 "tsift memory status {} --json",
326 shell_quote(root.to_string_lossy().as_ref())
327 ),
328 )
329 .with_provenance(provenance.clone());
330 nodes.push(node_with_content_freshness(session_node)?);
331 }
332
333 insert_semantic_edge(
334 &mut edge_map,
335 GraphEdge::new(
336 session_handle.clone(),
337 event_id.clone(),
338 "records_memory_event",
339 )
340 .with_property("label", "tsift-memory session event")
341 .with_provenance(provenance.clone()),
342 );
343 insert_semantic_edge(
344 &mut edge_map,
345 GraphEdge::new(
346 session_handle,
347 source_handle.clone(),
348 "records_memory_source",
349 )
350 .with_property("label", "tsift-memory session source")
351 .with_provenance(provenance.clone()),
352 );
353 }
354
355 let label = memory_event_label(event);
356 let mut event_node = GraphNode::new(event_id.clone(), "memory_event", event.kind.as_str())
357 .with_property("handle", event_id.clone())
358 .with_property("ref_id", event.source_ref.clone())
359 .with_property("source_ref", event.source_ref.clone())
360 .with_property("provider", "tsift-memory")
361 .with_property("memory_kind", event.kind.as_str())
362 .with_property("imported_from", imported_from)
363 .with_property("text_preview", truncate_for_compact(&event.text, 240))
364 .with_property("token_estimate", event.token_estimate.to_string())
365 .with_property(
366 "expand",
367 format!(
368 "tsift memory status {} --json",
369 shell_quote(root.to_string_lossy().as_ref())
370 ),
371 )
372 .with_provenance(provenance.clone());
373 if let Some(session_id) = &event.session_id {
374 event_node = event_node.with_property("session_id", session_id.clone());
375 }
376 if let Some(observed_at_unix) = event.observed_at_unix {
377 event_node = event_node.with_property("observed_at_unix", observed_at_unix.to_string());
378 }
379 if let Some(imported_id) = &event.imported_id {
380 event_node = event_node.with_property("imported_id", imported_id.clone());
381 }
382 nodes.push(node_with_content_freshness(event_node)?);
383
384 let mut source_node = GraphNode::new(source_handle.clone(), "source_handle", label.clone())
385 .with_property("handle", source_handle.clone())
386 .with_property("ref_id", event.source_ref.clone())
387 .with_property("source_ref", event.source_ref.clone())
388 .with_property("provider", "tsift-memory")
389 .with_property("memory_kind", event.kind.as_str())
390 .with_property("imported_from", imported_from)
391 .with_property("text_preview", truncate_for_compact(&event.text, 240))
392 .with_property("token_estimate", event.token_estimate.to_string())
393 .with_property(
394 "expand",
395 format!(
396 "tsift memory status {} --json",
397 shell_quote(root.to_string_lossy().as_ref())
398 ),
399 )
400 .with_provenance(provenance.clone());
401 if let Some(session_id) = &event.session_id {
402 source_node = source_node.with_property("session_id", session_id.clone());
403 }
404 if let Some(observed_at_unix) = event.observed_at_unix {
405 source_node =
406 source_node.with_property("observed_at_unix", observed_at_unix.to_string());
407 }
408 if let Some(imported_id) = &event.imported_id {
409 source_node = source_node.with_property("imported_id", imported_id.clone());
410 }
411 nodes.push(node_with_content_freshness(source_node)?);
412
413 insert_semantic_edge(
414 &mut edge_map,
415 GraphEdge::new(event_id.clone(), source_handle.clone(), "projects_source")
416 .with_property("label", "tsift-memory source projection")
417 .with_provenance(provenance.clone()),
418 );
419
420 let semantic_text = format!("{} {}", label, event.text);
421 let semantic_node =
422 GraphNode::new(semantic_handle.clone(), "semantic_concept", label.clone())
423 .with_property("handle", semantic_handle.clone())
424 .with_property("ref_id", event.source_ref.clone())
425 .with_property("detail", "semantic row from tsift-memory")
426 .with_property("source_ref", event.source_ref.clone())
427 .with_property("provider", "tsift-memory")
428 .with_property("memory_kind", event.kind.as_str())
429 .with_property("imported_from", imported_from)
430 .with_property("embedding_model", SEMANTIC_EMBEDDING_MODEL)
431 .with_property("embedding", semantic_embedding_property(&semantic_text))
432 .with_property(
433 "expand",
434 semantic_related_command(root, &label, SemanticRelatedKind::Concept),
435 )
436 .with_provenance(provenance.clone());
437 nodes.push(node_with_content_freshness(semantic_node)?);
438
439 insert_semantic_edge(
440 &mut edge_map,
441 GraphEdge::new(
442 source_handle.clone(),
443 semantic_handle.clone(),
444 "mentions_concept",
445 )
446 .with_property("label", "tsift-memory semantic source")
447 .with_provenance(provenance.clone()),
448 );
449 }
450
451 for edge in edge_map.into_values() {
452 edges.push(edge_with_content_freshness(edge)?);
453 }
454
455 Ok(())
456}
457
458fn memory_event_key(event: &MemoryEvent) -> String {
459 match (event.imported_from.as_deref(), event.imported_id.as_deref()) {
460 (Some(imported_from), Some(imported_id)) => {
461 format!("{imported_from}:{imported_id}")
462 }
463 _ => event.stable_id(),
464 }
465}
466
467fn memory_event_label(event: &MemoryEvent) -> String {
468 let first_line = event
469 .text
470 .lines()
471 .map(str::trim)
472 .find(|line| !line.is_empty())
473 .unwrap_or(event.kind.as_str());
474 match event.kind.as_str() {
475 "imported_observation" => {
476 let observation_type = event
477 .metadata
478 .get("observation_type")
479 .map(String::as_str)
480 .unwrap_or("observation");
481 truncate_for_compact(&format!("{observation_type}: {first_line}"), 80)
482 }
483 "imported_session_summary" => truncate_for_compact(&format!("summary: {first_line}"), 80),
484 "imported_user_prompt" => truncate_for_compact(&format!("prompt: {first_line}"), 80),
485 _ => truncate_for_compact(first_line, 80),
486 }
487}
488
489fn truncate_for_compact(input: &str, max_chars: usize) -> String {
490 let trimmed = input.trim();
491 let count = trimmed.chars().count();
492 if count <= max_chars {
493 return trimmed.to_string();
494 }
495 let prefix: String = trimmed.chars().take(max_chars.saturating_sub(3)).collect();
496 format!("{prefix}...")
497}
498
499fn stable_handle(prefix: &str, key: &str) -> String {
500 let mut hasher = blake3::Hasher::new();
501 hasher.update(prefix.as_bytes());
502 hasher.update(&[0]);
503 hasher.update(key.as_bytes());
504 let hex = hasher.finalize().to_hex();
505 format!("{prefix}-{}", &hex[..10])
506}
507
508fn content_hash<T: Serialize>(value: &T) -> Result<String> {
509 let bytes = serde_json::to_vec(value)?;
510 Ok(blake3::hash(&bytes).to_hex().to_string())
511}
512
513fn node_with_content_freshness(mut node: GraphNode) -> Result<GraphNode> {
514 let mut hashable = node.clone();
515 hashable.freshness = None;
516 node.freshness = Some(GraphFreshness::content_hash(content_hash(&hashable)?));
517 Ok(node)
518}
519
520fn edge_with_content_freshness(mut edge: GraphEdge) -> Result<GraphEdge> {
521 let mut hashable = edge.clone();
522 hashable.freshness = None;
523 edge.freshness = Some(GraphFreshness::content_hash(content_hash(&hashable)?));
524 Ok(edge)
525}
526
527#[derive(Clone, Copy)]
528enum SemanticRelatedKind {
529 Concept,
530}
531
532fn semantic_related_kind_name(kind: SemanticRelatedKind) -> &'static str {
533 match kind {
534 SemanticRelatedKind::Concept => "concept",
535 }
536}
537
538fn semantic_related_command(root: &Path, query: &str, kind: SemanticRelatedKind) -> String {
539 format!(
540 "tsift semantic {} --path {} --kind {} --limit 10",
541 shell_quote(query),
542 shell_quote(root.to_string_lossy().as_ref()),
543 semantic_related_kind_name(kind)
544 )
545}
546
547fn semantic_embedding(input: &str) -> Vec<f64> {
548 let mut vector = vec![0.0; SEMANTIC_EMBEDDING_DIM];
549 let mut tokens = traversal_tokens(input);
550 if tokens.is_empty() {
551 let trimmed = input.trim().to_ascii_lowercase();
552 if !trimmed.is_empty() {
553 tokens.insert(trimmed);
554 }
555 }
556
557 for token in tokens {
558 let hash = blake3::hash(token.as_bytes());
559 let bytes = hash.as_bytes();
560 let idx = usize::from(bytes[0]) % SEMANTIC_EMBEDDING_DIM;
561 let sign = if bytes[1] & 1 == 0 { 1.0 } else { -1.0 };
562 vector[idx] += sign;
563 }
564
565 let norm = vector.iter().map(|value| value * value).sum::<f64>().sqrt();
566 if norm > 0.0 {
567 for value in &mut vector {
568 *value /= norm;
569 }
570 }
571 vector
572}
573
574fn semantic_embedding_property(input: &str) -> String {
575 semantic_embedding(input)
576 .iter()
577 .map(|value| format!("{value:.6}"))
578 .collect::<Vec<_>>()
579 .join(",")
580}
581
582fn traversal_tokens(input: &str) -> BTreeSet<String> {
583 input
584 .split(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '_' || ch == '-'))
585 .flat_map(|part| part.split(['_', '-']))
586 .map(str::trim)
587 .filter(|part| part.len() >= 3)
588 .map(|part| part.to_ascii_lowercase())
589 .collect()
590}
591
592fn insert_semantic_edge(
593 edge_map: &mut BTreeMap<(String, String, String), GraphEdge>,
594 edge: GraphEdge,
595) {
596 edge_map
597 .entry((edge.from_id.clone(), edge.to_id.clone(), edge.kind.clone()))
598 .or_insert(edge);
599}
600
601fn shell_quote(s: &str) -> String {
602 let unquoted =
603 if (s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')) {
604 &s[1..s.len() - 1]
605 } else {
606 s
607 };
608
609 if unquoted
610 .chars()
611 .all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.' || c == '/')
612 {
613 format!("\"{}\"", unquoted)
614 } else {
615 format!(
616 "\"{}\"",
617 unquoted.replace('\\', "\\\\").replace('"', "\\\"")
618 )
619 }
620}
621
622#[cfg(test)]
623mod tests {
624 use super::*;
625 use tempfile::TempDir;
626 use tsift_memory::{MemoryEventKind, MemoryStore, default_memory_db_path};
627
628 #[test]
629 fn project_memory_events_links_events_to_sessions() {
630 let event = MemoryEvent::new(MemoryEventKind::ResponseSummary, "session.md", "done")
631 .with_session_id("session-a");
632 let projection = project_memory_events(&[event]);
633 assert_eq!(projection.nodes.len(), 2);
634 assert_eq!(projection.edges.len(), 1);
635 assert!(
636 projection
637 .nodes
638 .iter()
639 .any(|node| node.kind == "memory_session")
640 );
641 assert!(
642 projection
643 .nodes
644 .iter()
645 .any(|node| node.kind == "memory_event")
646 );
647 }
648
649 #[test]
650 fn rank_memory_events_prefers_recent_relevant_events() {
651 let now = 1_700_000_000;
652 let old = MemoryEvent::new(
653 MemoryEventKind::ResponseSummary,
654 "old",
655 "graph retrieval design shipped",
656 )
657 .with_observed_at_unix(now - 30 * 24 * 3600);
658 let recent = MemoryEvent::new(
659 MemoryEventKind::ResponseSummary,
660 "recent",
661 "graph retrieval follow-up",
662 )
663 .with_observed_at_unix(now - 60);
664 let config = MemoryDecayConfig {
665 half_life_secs: 7.0 * 24.0 * 3600.0,
666 lexical_weight: 0.5,
667 recency_weight: 0.5,
668 };
669 let ranked = rank_memory_events(&[old, recent], "graph retrieval", now, config, 10);
670 assert_eq!(ranked[0].event.source_ref, "recent");
671 }
672
673 #[test]
674 fn rank_memory_events_keeps_lexical_hits_without_timestamp() {
675 let now = 1_700_000_000;
676 let event = MemoryEvent::new(
677 MemoryEventKind::ResponseSummary,
678 "untimed",
679 "semantic graph memory",
680 );
681 let off_topic_fresh = MemoryEvent::new(
682 MemoryEventKind::ResponseSummary,
683 "fresh",
684 "unrelated build log output",
685 )
686 .with_observed_at_unix(now - 10);
687 let config = MemoryDecayConfig::default();
688 let ranked = rank_memory_events(
689 &[event.clone(), off_topic_fresh],
690 "semantic graph memory",
691 now,
692 config,
693 10,
694 );
695 assert_eq!(ranked[0].event.source_ref, event.source_ref);
696 }
697
698 #[test]
699 fn plan_memory_query_carries_default_decay_config() {
700 let plan = plan_memory_query("graph rag", 5, 1500).unwrap();
701 assert_eq!(plan.decay, MemoryDecayConfig::default());
702 assert!(
703 plan.next_commands
704 .iter()
705 .any(|cmd| cmd.contains("project-graph"))
706 );
707 }
708
709 #[test]
710 fn project_memory_into_graph_persists_memory_nodes() {
711 let dir = TempDir::new().unwrap();
712 let root = dir.path();
713 let memory_db = default_memory_db_path(root);
714 std::fs::create_dir_all(memory_db.parent().unwrap()).unwrap();
715
716 let store = MemoryStore::open_or_create(&memory_db).unwrap();
717 let mut prompt = MemoryEvent::new(
718 MemoryEventKind::PromptTarget,
719 "session.md",
720 "run the gated backlog items",
721 );
722 prompt.session_id = Some("sess-1".to_string());
723 prompt.observed_at_unix = Some(1_700_000_000);
724 let mut response = MemoryEvent::new(
725 MemoryEventKind::ResponseSummary,
726 "session.md",
727 "decay weighted retrieval shipped",
728 );
729 response.session_id = Some("sess-1".to_string());
730 response.observed_at_unix = Some(1_700_000_100);
731 store.insert_event(&prompt).unwrap();
732 store.insert_event(&response).unwrap();
733
734 let graph_db = root.join(".tsift").join("graph.db");
735 let report = project_memory_into_graph(&memory_db, &graph_db, 100).unwrap();
736 assert_eq!(report.events_projected, 2);
737 assert!(
738 report.nodes_upserted >= 3,
739 "two events + one session node, got {}",
740 report.nodes_upserted
741 );
742 assert!(
743 report.edges_upserted >= 2,
744 "session records each event, got {}",
745 report.edges_upserted
746 );
747
748 let conn = rusqlite::Connection::open(&graph_db).unwrap();
749 let memory_events: i64 = conn
750 .query_row(
751 "SELECT COUNT(*) FROM graph_nodes WHERE kind = 'memory_event'",
752 [],
753 |row| row.get(0),
754 )
755 .unwrap();
756 assert_eq!(memory_events, 2);
757 let sessions: i64 = conn
758 .query_row(
759 "SELECT COUNT(*) FROM graph_nodes WHERE kind = 'memory_session'",
760 [],
761 |row| row.get(0),
762 )
763 .unwrap();
764 assert_eq!(sessions, 1);
765 }
766
767 #[test]
768 fn traversal_projection_adds_semantic_memory_rows() {
769 let dir = TempDir::new().unwrap();
770 let event = MemoryEvent::new(
771 MemoryEventKind::ResponseSummary,
772 "session.md",
773 "semantic memory graph",
774 )
775 .with_session_id("sess-1")
776 .with_observed_at_unix(1_700_000_000);
777 let mut nodes = Vec::new();
778 let mut edges = Vec::new();
779 append_memory_events_as_traversal_rows(dir.path(), &[event], &mut nodes, &mut edges)
780 .unwrap();
781
782 assert!(nodes.iter().any(|node| node.kind == "memory_event"));
783 assert!(nodes.iter().any(|node| {
784 node.kind == "semantic_concept"
785 && node.properties.get("provider") == Some(&"tsift-memory".to_string())
786 }));
787 assert!(edges.iter().any(|edge| edge.kind == "mentions_concept"));
788 }
789}