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    /// Conflict-edge counts `(contradicts, supersedes)` in a single O(edges)
269    /// pass, deduping undirected contradictions. See
270    /// [`CsrGraph::conflict_edge_counts`].
271    pub fn conflict_edge_counts(&self) -> (usize, usize) {
272        self.graph.read().conflict_edge_counts()
273    }
274}
275
276impl Default for GraphManager {
277    fn default() -> Self {
278        Self::new()
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use mentedb_core::edge::EdgeType;
286
287    fn make_edge(src: MemoryId, tgt: MemoryId, etype: EdgeType) -> MemoryEdge {
288        MemoryEdge {
289            source: src,
290            target: tgt,
291            edge_type: etype,
292            weight: 0.8,
293            created_at: 1000,
294            valid_from: None,
295            valid_until: None,
296            label: None,
297        }
298    }
299
300    #[test]
301    fn test_add_memory_and_relationship() {
302        let mgr = GraphManager::new();
303        let a = MemoryId::new();
304        let b = MemoryId::new();
305        mgr.add_memory(a);
306        mgr.add_memory(b);
307        assert!(
308            mgr.add_relationship(&make_edge(a, b, EdgeType::Caused))
309                .is_ok()
310        );
311    }
312
313    #[test]
314    fn test_relationship_missing_node() {
315        let mgr = GraphManager::new();
316        let a = MemoryId::new();
317        let b = MemoryId::new();
318        mgr.add_memory(a);
319        // b not added
320        assert!(
321            mgr.add_relationship(&make_edge(a, b, EdgeType::Caused))
322                .is_err()
323        );
324    }
325
326    #[test]
327    fn test_context_subgraph() {
328        let mgr = GraphManager::new();
329        let a = MemoryId::new();
330        let b = MemoryId::new();
331        let c = MemoryId::new();
332        mgr.add_memory(a);
333        mgr.add_memory(b);
334        mgr.add_memory(c);
335        mgr.add_relationship(&make_edge(a, b, EdgeType::Caused))
336            .unwrap();
337        mgr.add_relationship(&make_edge(b, c, EdgeType::Related))
338            .unwrap();
339
340        let (nodes, edges) = mgr.get_context_subgraph(a, 2);
341        assert_eq!(nodes.len(), 3);
342        assert_eq!(edges.len(), 2);
343    }
344
345    #[test]
346    fn test_compact() {
347        let mgr = GraphManager::new();
348        let a = MemoryId::new();
349        let b = MemoryId::new();
350        mgr.add_memory(a);
351        mgr.add_memory(b);
352        mgr.add_relationship(&make_edge(a, b, EdgeType::Caused))
353            .unwrap();
354        mgr.compact();
355
356        let out = mgr.graph().outgoing(a);
357        assert_eq!(out.len(), 1);
358    }
359
360    #[test]
361    fn test_edge_log_replay_without_snapshot() {
362        let dir = tempfile::tempdir().unwrap();
363        let a = MemoryId::new();
364        let b = MemoryId::new();
365        {
366            let mgr = GraphManager::open(dir.path()).unwrap();
367            mgr.add_memory(a);
368            mgr.add_memory(b);
369            mgr.add_relationship(&make_edge(a, b, EdgeType::Caused))
370                .unwrap();
371            // No save(): the mutations exist only in the edge log.
372        }
373        {
374            let mgr = GraphManager::open(dir.path()).unwrap();
375            assert!(mgr.read_graph().contains_node(a));
376            let out = mgr.read_graph().outgoing(a);
377            assert_eq!(out.len(), 1);
378            assert_eq!(out[0].0, b);
379        }
380    }
381
382    #[test]
383    fn test_save_truncates_edge_log() {
384        let dir = tempfile::tempdir().unwrap();
385        let a = MemoryId::new();
386        let b = MemoryId::new();
387        {
388            let mgr = GraphManager::open(dir.path()).unwrap();
389            mgr.add_memory(a);
390            mgr.add_memory(b);
391            mgr.add_relationship(&make_edge(a, b, EdgeType::Related))
392                .unwrap();
393            mgr.save(dir.path()).unwrap();
394            let log_len = std::fs::metadata(dir.path().join(super::EDGE_LOG_FILE))
395                .unwrap()
396                .len();
397            assert_eq!(log_len, 0, "save must truncate the edge log");
398        }
399        {
400            // Snapshot alone restores everything; further mutations keep logging.
401            let mgr = GraphManager::open(dir.path()).unwrap();
402            assert_eq!(mgr.read_graph().outgoing(a).len(), 1);
403            let c = MemoryId::new();
404            mgr.add_memory(c);
405            mgr.add_relationship(&make_edge(a, c, EdgeType::Supports))
406                .unwrap();
407        }
408        {
409            let mgr = GraphManager::open(dir.path()).unwrap();
410            assert_eq!(
411                mgr.read_graph().outgoing(a).len(),
412                2,
413                "post-save mutations must replay from the fresh log"
414            );
415        }
416    }
417
418    #[test]
419    fn test_belief_propagation() {
420        let mgr = GraphManager::new();
421        let a = MemoryId::new();
422        let b = MemoryId::new();
423        mgr.add_memory(a);
424        mgr.add_memory(b);
425        mgr.add_relationship(&MemoryEdge {
426            source: a,
427            target: b,
428            edge_type: EdgeType::Caused,
429            weight: 1.0,
430            created_at: 1000,
431            valid_from: None,
432            valid_until: None,
433            label: None,
434        })
435        .unwrap();
436
437        let results = mgr.propagate_belief_change(a, 0.5);
438        assert!(results.len() >= 2);
439    }
440}