Skip to main content

tsift_memgraphrag/
lib.rs

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}