Skip to main content

mentedb_graph/
manager.rs

1//! High-level knowledge graph manager.
2
3use std::io::Write;
4use std::path::{Path, PathBuf};
5
6use mentedb_core::edge::MemoryEdge;
7use mentedb_core::error::{MenteError, MenteResult};
8use mentedb_core::types::MemoryId;
9use parking_lot::{Mutex, RwLock};
10use serde::{Deserialize, Serialize};
11use tracing::warn;
12
13use crate::belief::propagate_update;
14use crate::contradiction::find_contradictions;
15use crate::csr::CsrGraph;
16use crate::traversal::extract_subgraph;
17
18/// A single durable graph mutation, appended to the edge log as one JSON line.
19#[derive(Debug, Serialize, Deserialize)]
20enum LogRecord {
21    AddNode(MemoryId),
22    RemoveNode(MemoryId),
23    AddEdge(MemoryEdge),
24}
25
26/// Append-only log of graph mutations since the last snapshot.
27///
28/// The snapshot (`graph.json`) is only written on `save()`; without this log
29/// every edge created since the last flush would be lost on crash while the
30/// memories themselves survive via the storage WAL.
31struct EdgeLog {
32    file: std::fs::File,
33}
34
35const EDGE_LOG_FILE: &str = "edges.jsonl";
36
37impl EdgeLog {
38    fn append(&mut self, record: &LogRecord) -> MenteResult<()> {
39        let mut line =
40            serde_json::to_vec(record).map_err(|e| MenteError::Serialization(e.to_string()))?;
41        line.push(b'\n');
42        self.file.write_all(&line)?;
43        self.file.sync_data()?;
44        Ok(())
45    }
46}
47
48/// Owns a `CsrGraph` and provides high-level graph operations.
49///
50/// All methods take `&self` — internal `RwLock` handles concurrency.
51pub struct GraphManager {
52    graph: RwLock<CsrGraph>,
53    /// Directory-backed managers log mutations for crash durability.
54    /// `None` for purely in-memory managers created with `new()`.
55    log: Mutex<Option<EdgeLog>>,
56    dir: Option<PathBuf>,
57}
58
59impl GraphManager {
60    /// Creates a new in-memory graph manager (no durability log).
61    pub fn new() -> Self {
62        Self {
63            graph: RwLock::new(CsrGraph::new()),
64            log: Mutex::new(None),
65            dir: None,
66        }
67    }
68
69    /// Open a directory-backed graph manager.
70    ///
71    /// Loads the last snapshot (`graph.json`) if present, replays the edge log
72    /// (`edges.jsonl`) on top of it, and keeps the log open for appending, so
73    /// graph mutations are crash-durable between snapshots.
74    pub fn open(dir: &Path) -> MenteResult<Self> {
75        std::fs::create_dir_all(dir)?;
76
77        let snapshot_path = dir.join("graph.json");
78        let mut graph = if snapshot_path.exists() {
79            CsrGraph::load(&snapshot_path)?
80        } else {
81            CsrGraph::new()
82        };
83
84        let log_path = dir.join(EDGE_LOG_FILE);
85        if log_path.exists() {
86            let contents = std::fs::read_to_string(&log_path)?;
87            let lines: Vec<&str> = contents.lines().collect();
88            for (i, line) in lines.iter().enumerate() {
89                if line.trim().is_empty() {
90                    continue;
91                }
92                match serde_json::from_str::<LogRecord>(line) {
93                    Ok(LogRecord::AddNode(id)) => {
94                        graph.add_node(id);
95                    }
96                    Ok(LogRecord::RemoveNode(id)) => {
97                        graph.remove_node(id);
98                    }
99                    Ok(LogRecord::AddEdge(edge)) => {
100                        graph.add_node(edge.source);
101                        graph.add_node(edge.target);
102                        graph.add_edge(&edge);
103                    }
104                    Err(e) => {
105                        // A torn final line is expected after a crash mid-append;
106                        // anything else is corruption worth surfacing.
107                        if i + 1 == lines.len() {
108                            warn!("dropping torn final edge log line: {e}");
109                        } else {
110                            return Err(MenteError::Serialization(format!(
111                                "corrupt edge log line {}: {e}",
112                                i + 1
113                            )));
114                        }
115                    }
116                }
117            }
118        }
119
120        let file = std::fs::OpenOptions::new()
121            .create(true)
122            .append(true)
123            .open(&log_path)?;
124
125        Ok(Self {
126            graph: RwLock::new(graph),
127            log: Mutex::new(Some(EdgeLog { file })),
128            dir: Some(dir.to_path_buf()),
129        })
130    }
131
132    /// Save a graph snapshot to the given directory and truncate the edge log.
133    pub fn save(&self, dir: &Path) -> MenteResult<()> {
134        std::fs::create_dir_all(dir)?;
135        self.graph.read().save(&dir.join("graph.json"))?;
136
137        // The snapshot now contains everything the log recorded.
138        let mut log = self.log.lock();
139        if log.is_some() && self.dir.as_deref() == Some(dir) {
140            let log_path = dir.join(EDGE_LOG_FILE);
141            let file = std::fs::OpenOptions::new()
142                .create(true)
143                .write(true)
144                .truncate(true)
145                .open(&log_path)?;
146            file.sync_data()?;
147            drop(file);
148            let file = std::fs::OpenOptions::new()
149                .create(true)
150                .append(true)
151                .open(&log_path)?;
152            *log = Some(EdgeLog { file });
153        }
154        Ok(())
155    }
156
157    /// Load the graph snapshot from the given directory (no durability log).
158    ///
159    /// Prefer `open()`, which also replays and maintains the edge log.
160    pub fn load(dir: &Path) -> MenteResult<Self> {
161        let graph = CsrGraph::load(&dir.join("graph.json"))?;
162        Ok(Self {
163            graph: RwLock::new(graph),
164            log: Mutex::new(None),
165            dir: None,
166        })
167    }
168
169    fn log_record(&self, record: &LogRecord) {
170        if let Some(log) = self.log.lock().as_mut()
171            && let Err(e) = log.append(record)
172        {
173            warn!("failed to append to graph edge log: {e}");
174        }
175    }
176
177    /// Register a memory node in the graph.
178    pub fn add_memory(&self, id: MemoryId) {
179        let is_new = {
180            let mut g = self.graph.write();
181            let existed = g.contains_node(id);
182            g.add_node(id);
183            !existed
184        };
185        if is_new {
186            self.log_record(&LogRecord::AddNode(id));
187        }
188    }
189
190    /// Remove a memory node and all its edges.
191    pub fn remove_memory(&self, id: MemoryId) {
192        self.graph.write().remove_node(id);
193        self.log_record(&LogRecord::RemoveNode(id));
194    }
195
196    /// Add a relationship (edge) between two memory nodes.
197    pub fn add_relationship(&self, edge: &MemoryEdge) -> MenteResult<()> {
198        {
199            let mut g = self.graph.write();
200            if !g.contains_node(edge.source) {
201                return Err(MenteError::MemoryNotFound(edge.source));
202            }
203            if !g.contains_node(edge.target) {
204                return Err(MenteError::MemoryNotFound(edge.target));
205            }
206            g.add_edge(edge);
207        }
208        self.log_record(&LogRecord::AddEdge(edge.clone()));
209        Ok(())
210    }
211
212    /// Extract a context subgraph around a center node.
213    pub fn get_context_subgraph(
214        &self,
215        center: MemoryId,
216        depth: usize,
217    ) -> (Vec<MemoryId>, Vec<MemoryEdge>) {
218        extract_subgraph(&self.graph.read(), center, depth)
219    }
220
221    /// Propagate a confidence change through the graph.
222    pub fn propagate_belief_change(
223        &self,
224        id: MemoryId,
225        new_confidence: f32,
226    ) -> Vec<(MemoryId, f32)> {
227        propagate_update(&self.graph.read(), id, new_confidence)
228    }
229
230    /// Find all nodes that contradict the given node.
231    pub fn find_all_contradictions(&self, id: MemoryId) -> Vec<MemoryId> {
232        find_contradictions(&self.graph.read(), id)
233    }
234
235    /// Merge the delta log into CSR/CSC compressed storage.
236    pub fn compact(&self) {
237        self.graph.write().compact();
238    }
239
240    /// Strengthen an edge weight (Hebbian learning: neurons that fire together wire together).
241    pub fn strengthen_edge(&self, source: MemoryId, target: MemoryId, delta: f32) {
242        self.graph.write().strengthen_edge(source, target, delta);
243    }
244
245    /// Access the underlying graph for read-only traversals.
246    ///
247    /// Returns a read guard — hold it only briefly to avoid blocking writers.
248    pub fn read_graph(&self) -> parking_lot::RwLockReadGuard<'_, CsrGraph> {
249        self.graph.read()
250    }
251
252    /// Alias for `read_graph()` — backward compatible access to the CsrGraph.
253    pub fn graph(&self) -> parking_lot::RwLockReadGuard<'_, CsrGraph> {
254        self.graph.read()
255    }
256}
257
258impl Default for GraphManager {
259    fn default() -> Self {
260        Self::new()
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267    use mentedb_core::edge::EdgeType;
268
269    fn make_edge(src: MemoryId, tgt: MemoryId, etype: EdgeType) -> MemoryEdge {
270        MemoryEdge {
271            source: src,
272            target: tgt,
273            edge_type: etype,
274            weight: 0.8,
275            created_at: 1000,
276            valid_from: None,
277            valid_until: None,
278            label: None,
279        }
280    }
281
282    #[test]
283    fn test_add_memory_and_relationship() {
284        let mgr = GraphManager::new();
285        let a = MemoryId::new();
286        let b = MemoryId::new();
287        mgr.add_memory(a);
288        mgr.add_memory(b);
289        assert!(
290            mgr.add_relationship(&make_edge(a, b, EdgeType::Caused))
291                .is_ok()
292        );
293    }
294
295    #[test]
296    fn test_relationship_missing_node() {
297        let mgr = GraphManager::new();
298        let a = MemoryId::new();
299        let b = MemoryId::new();
300        mgr.add_memory(a);
301        // b not added
302        assert!(
303            mgr.add_relationship(&make_edge(a, b, EdgeType::Caused))
304                .is_err()
305        );
306    }
307
308    #[test]
309    fn test_context_subgraph() {
310        let mgr = GraphManager::new();
311        let a = MemoryId::new();
312        let b = MemoryId::new();
313        let c = MemoryId::new();
314        mgr.add_memory(a);
315        mgr.add_memory(b);
316        mgr.add_memory(c);
317        mgr.add_relationship(&make_edge(a, b, EdgeType::Caused))
318            .unwrap();
319        mgr.add_relationship(&make_edge(b, c, EdgeType::Related))
320            .unwrap();
321
322        let (nodes, edges) = mgr.get_context_subgraph(a, 2);
323        assert_eq!(nodes.len(), 3);
324        assert_eq!(edges.len(), 2);
325    }
326
327    #[test]
328    fn test_compact() {
329        let mgr = GraphManager::new();
330        let a = MemoryId::new();
331        let b = MemoryId::new();
332        mgr.add_memory(a);
333        mgr.add_memory(b);
334        mgr.add_relationship(&make_edge(a, b, EdgeType::Caused))
335            .unwrap();
336        mgr.compact();
337
338        let out = mgr.graph().outgoing(a);
339        assert_eq!(out.len(), 1);
340    }
341
342    #[test]
343    fn test_edge_log_replay_without_snapshot() {
344        let dir = tempfile::tempdir().unwrap();
345        let a = MemoryId::new();
346        let b = MemoryId::new();
347        {
348            let mgr = GraphManager::open(dir.path()).unwrap();
349            mgr.add_memory(a);
350            mgr.add_memory(b);
351            mgr.add_relationship(&make_edge(a, b, EdgeType::Caused))
352                .unwrap();
353            // No save(): the mutations exist only in the edge log.
354        }
355        {
356            let mgr = GraphManager::open(dir.path()).unwrap();
357            assert!(mgr.read_graph().contains_node(a));
358            let out = mgr.read_graph().outgoing(a);
359            assert_eq!(out.len(), 1);
360            assert_eq!(out[0].0, b);
361        }
362    }
363
364    #[test]
365    fn test_save_truncates_edge_log() {
366        let dir = tempfile::tempdir().unwrap();
367        let a = MemoryId::new();
368        let b = MemoryId::new();
369        {
370            let mgr = GraphManager::open(dir.path()).unwrap();
371            mgr.add_memory(a);
372            mgr.add_memory(b);
373            mgr.add_relationship(&make_edge(a, b, EdgeType::Related))
374                .unwrap();
375            mgr.save(dir.path()).unwrap();
376            let log_len = std::fs::metadata(dir.path().join(super::EDGE_LOG_FILE))
377                .unwrap()
378                .len();
379            assert_eq!(log_len, 0, "save must truncate the edge log");
380        }
381        {
382            // Snapshot alone restores everything; further mutations keep logging.
383            let mgr = GraphManager::open(dir.path()).unwrap();
384            assert_eq!(mgr.read_graph().outgoing(a).len(), 1);
385            let c = MemoryId::new();
386            mgr.add_memory(c);
387            mgr.add_relationship(&make_edge(a, c, EdgeType::Supports))
388                .unwrap();
389        }
390        {
391            let mgr = GraphManager::open(dir.path()).unwrap();
392            assert_eq!(
393                mgr.read_graph().outgoing(a).len(),
394                2,
395                "post-save mutations must replay from the fresh log"
396            );
397        }
398    }
399
400    #[test]
401    fn test_belief_propagation() {
402        let mgr = GraphManager::new();
403        let a = MemoryId::new();
404        let b = MemoryId::new();
405        mgr.add_memory(a);
406        mgr.add_memory(b);
407        mgr.add_relationship(&MemoryEdge {
408            source: a,
409            target: b,
410            edge_type: EdgeType::Caused,
411            weight: 1.0,
412            created_at: 1000,
413            valid_from: None,
414            valid_until: None,
415            label: None,
416        })
417        .unwrap();
418
419        let results = mgr.propagate_belief_change(a, 0.5);
420        assert!(results.len() >= 2);
421    }
422}