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    /// Remove every edge whose type is in `types`, preserving other types.
241    /// Returns the number removed.
242    ///
243    /// There is no per-removal log record, so this is not crash-durable on its
244    /// own: the caller must `save()` afterward to persist a fresh snapshot and
245    /// truncate the edge log (otherwise replaying the log would re-add the
246    /// removed edges). Intended for one-time bulk cleanup.
247    pub fn remove_edges_of_types(&self, types: &[mentedb_core::edge::EdgeType]) -> usize {
248        self.graph.write().remove_edges_of_types(types)
249    }
250
251    /// Strengthen an edge weight (Hebbian learning: neurons that fire together wire together).
252    pub fn strengthen_edge(&self, source: MemoryId, target: MemoryId, delta: f32) {
253        self.graph.write().strengthen_edge(source, target, delta);
254    }
255
256    /// Access the underlying graph for read-only traversals.
257    ///
258    /// Returns a read guard — hold it only briefly to avoid blocking writers.
259    pub fn read_graph(&self) -> parking_lot::RwLockReadGuard<'_, CsrGraph> {
260        self.graph.read()
261    }
262
263    /// Alias for `read_graph()` — backward compatible access to the CsrGraph.
264    pub fn graph(&self) -> parking_lot::RwLockReadGuard<'_, CsrGraph> {
265        self.graph.read()
266    }
267}
268
269impl Default for GraphManager {
270    fn default() -> Self {
271        Self::new()
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278    use mentedb_core::edge::EdgeType;
279
280    fn make_edge(src: MemoryId, tgt: MemoryId, etype: EdgeType) -> MemoryEdge {
281        MemoryEdge {
282            source: src,
283            target: tgt,
284            edge_type: etype,
285            weight: 0.8,
286            created_at: 1000,
287            valid_from: None,
288            valid_until: None,
289            label: None,
290        }
291    }
292
293    #[test]
294    fn test_add_memory_and_relationship() {
295        let mgr = GraphManager::new();
296        let a = MemoryId::new();
297        let b = MemoryId::new();
298        mgr.add_memory(a);
299        mgr.add_memory(b);
300        assert!(
301            mgr.add_relationship(&make_edge(a, b, EdgeType::Caused))
302                .is_ok()
303        );
304    }
305
306    #[test]
307    fn test_relationship_missing_node() {
308        let mgr = GraphManager::new();
309        let a = MemoryId::new();
310        let b = MemoryId::new();
311        mgr.add_memory(a);
312        // b not added
313        assert!(
314            mgr.add_relationship(&make_edge(a, b, EdgeType::Caused))
315                .is_err()
316        );
317    }
318
319    #[test]
320    fn test_context_subgraph() {
321        let mgr = GraphManager::new();
322        let a = MemoryId::new();
323        let b = MemoryId::new();
324        let c = MemoryId::new();
325        mgr.add_memory(a);
326        mgr.add_memory(b);
327        mgr.add_memory(c);
328        mgr.add_relationship(&make_edge(a, b, EdgeType::Caused))
329            .unwrap();
330        mgr.add_relationship(&make_edge(b, c, EdgeType::Related))
331            .unwrap();
332
333        let (nodes, edges) = mgr.get_context_subgraph(a, 2);
334        assert_eq!(nodes.len(), 3);
335        assert_eq!(edges.len(), 2);
336    }
337
338    #[test]
339    fn test_compact() {
340        let mgr = GraphManager::new();
341        let a = MemoryId::new();
342        let b = MemoryId::new();
343        mgr.add_memory(a);
344        mgr.add_memory(b);
345        mgr.add_relationship(&make_edge(a, b, EdgeType::Caused))
346            .unwrap();
347        mgr.compact();
348
349        let out = mgr.graph().outgoing(a);
350        assert_eq!(out.len(), 1);
351    }
352
353    #[test]
354    fn test_edge_log_replay_without_snapshot() {
355        let dir = tempfile::tempdir().unwrap();
356        let a = MemoryId::new();
357        let b = MemoryId::new();
358        {
359            let mgr = GraphManager::open(dir.path()).unwrap();
360            mgr.add_memory(a);
361            mgr.add_memory(b);
362            mgr.add_relationship(&make_edge(a, b, EdgeType::Caused))
363                .unwrap();
364            // No save(): the mutations exist only in the edge log.
365        }
366        {
367            let mgr = GraphManager::open(dir.path()).unwrap();
368            assert!(mgr.read_graph().contains_node(a));
369            let out = mgr.read_graph().outgoing(a);
370            assert_eq!(out.len(), 1);
371            assert_eq!(out[0].0, b);
372        }
373    }
374
375    #[test]
376    fn test_save_truncates_edge_log() {
377        let dir = tempfile::tempdir().unwrap();
378        let a = MemoryId::new();
379        let b = MemoryId::new();
380        {
381            let mgr = GraphManager::open(dir.path()).unwrap();
382            mgr.add_memory(a);
383            mgr.add_memory(b);
384            mgr.add_relationship(&make_edge(a, b, EdgeType::Related))
385                .unwrap();
386            mgr.save(dir.path()).unwrap();
387            let log_len = std::fs::metadata(dir.path().join(super::EDGE_LOG_FILE))
388                .unwrap()
389                .len();
390            assert_eq!(log_len, 0, "save must truncate the edge log");
391        }
392        {
393            // Snapshot alone restores everything; further mutations keep logging.
394            let mgr = GraphManager::open(dir.path()).unwrap();
395            assert_eq!(mgr.read_graph().outgoing(a).len(), 1);
396            let c = MemoryId::new();
397            mgr.add_memory(c);
398            mgr.add_relationship(&make_edge(a, c, EdgeType::Supports))
399                .unwrap();
400        }
401        {
402            let mgr = GraphManager::open(dir.path()).unwrap();
403            assert_eq!(
404                mgr.read_graph().outgoing(a).len(),
405                2,
406                "post-save mutations must replay from the fresh log"
407            );
408        }
409    }
410
411    #[test]
412    fn test_belief_propagation() {
413        let mgr = GraphManager::new();
414        let a = MemoryId::new();
415        let b = MemoryId::new();
416        mgr.add_memory(a);
417        mgr.add_memory(b);
418        mgr.add_relationship(&MemoryEdge {
419            source: a,
420            target: b,
421            edge_type: EdgeType::Caused,
422            weight: 1.0,
423            created_at: 1000,
424            valid_from: None,
425            valid_until: None,
426            label: None,
427        })
428        .unwrap();
429
430        let results = mgr.propagate_belief_change(a, 0.5);
431        assert!(results.len() >= 2);
432    }
433}