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::{PropagationConfig, propagate_delta, 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    /// The damped confidence changes a change of `delta` at `root` implies for
231    /// the memories that depend on it ([`propagate_delta`]), root excluded.
232    /// Holds the graph read lock only for the in memory walk.
233    pub fn propagate_belief_delta(
234        &self,
235        root: MemoryId,
236        delta: f32,
237        config: &PropagationConfig,
238    ) -> Vec<(MemoryId, f32)> {
239        propagate_delta(&self.graph.read(), root, delta, config)
240    }
241
242    /// Find all nodes that contradict the given node.
243    pub fn find_all_contradictions(&self, id: MemoryId) -> Vec<MemoryId> {
244        find_contradictions(&self.graph.read(), id)
245    }
246
247    /// Merge the delta log into CSR/CSC compressed storage.
248    pub fn compact(&self) {
249        self.graph.write().compact();
250    }
251
252    /// Remove every edge whose type is in `types`, preserving other types.
253    /// Returns the number removed.
254    ///
255    /// There is no per-removal log record, so this is not crash-durable on its
256    /// own: the caller must `save()` afterward to persist a fresh snapshot and
257    /// truncate the edge log (otherwise replaying the log would re-add the
258    /// removed edges). Intended for one-time bulk cleanup.
259    pub fn remove_edges_of_types(&self, types: &[mentedb_core::edge::EdgeType]) -> usize {
260        self.graph.write().remove_edges_of_types(types)
261    }
262
263    /// Strengthen an edge weight (Hebbian learning: neurons that fire together wire together).
264    pub fn strengthen_edge(&self, source: MemoryId, target: MemoryId, delta: f32) {
265        self.graph.write().strengthen_edge(source, target, delta);
266    }
267
268    /// Access the underlying graph for read-only traversals.
269    ///
270    /// Returns a read guard — hold it only briefly to avoid blocking writers.
271    pub fn read_graph(&self) -> parking_lot::RwLockReadGuard<'_, CsrGraph> {
272        self.graph.read()
273    }
274
275    /// Alias for `read_graph()` — backward compatible access to the CsrGraph.
276    pub fn graph(&self) -> parking_lot::RwLockReadGuard<'_, CsrGraph> {
277        self.graph.read()
278    }
279
280    /// Conflict-edge counts `(contradicts, supersedes)` in a single O(edges)
281    /// pass, deduping undirected contradictions. See
282    /// [`CsrGraph::conflict_edge_counts`].
283    pub fn conflict_edge_counts(&self) -> (usize, usize) {
284        self.graph.read().conflict_edge_counts()
285    }
286
287    /// Every live conflict-class edge as `(source, target, type, weight)`, read
288    /// in O(conflicts) from the maintained pair list. Lets the dashboard conflict
289    /// view enumerate the pairs without walking the whole graph.
290    pub fn conflict_edges(
291        &self,
292    ) -> Vec<(
293        mentedb_core::types::MemoryId,
294        mentedb_core::types::MemoryId,
295        mentedb_core::edge::EdgeType,
296        f32,
297    )> {
298        self.graph.read().conflict_edges()
299    }
300}
301
302impl Default for GraphManager {
303    fn default() -> Self {
304        Self::new()
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use mentedb_core::edge::EdgeType;
312
313    fn make_edge(src: MemoryId, tgt: MemoryId, etype: EdgeType) -> MemoryEdge {
314        MemoryEdge {
315            source: src,
316            target: tgt,
317            edge_type: etype,
318            weight: 0.8,
319            created_at: 1000,
320            valid_from: None,
321            valid_until: None,
322            label: None,
323        }
324    }
325
326    #[test]
327    fn test_add_memory_and_relationship() {
328        let mgr = GraphManager::new();
329        let a = MemoryId::new();
330        let b = MemoryId::new();
331        mgr.add_memory(a);
332        mgr.add_memory(b);
333        assert!(
334            mgr.add_relationship(&make_edge(a, b, EdgeType::Caused))
335                .is_ok()
336        );
337    }
338
339    #[test]
340    fn test_relationship_missing_node() {
341        let mgr = GraphManager::new();
342        let a = MemoryId::new();
343        let b = MemoryId::new();
344        mgr.add_memory(a);
345        // b not added
346        assert!(
347            mgr.add_relationship(&make_edge(a, b, EdgeType::Caused))
348                .is_err()
349        );
350    }
351
352    #[test]
353    fn test_context_subgraph() {
354        let mgr = GraphManager::new();
355        let a = MemoryId::new();
356        let b = MemoryId::new();
357        let c = MemoryId::new();
358        mgr.add_memory(a);
359        mgr.add_memory(b);
360        mgr.add_memory(c);
361        mgr.add_relationship(&make_edge(a, b, EdgeType::Caused))
362            .unwrap();
363        mgr.add_relationship(&make_edge(b, c, EdgeType::Related))
364            .unwrap();
365
366        let (nodes, edges) = mgr.get_context_subgraph(a, 2);
367        assert_eq!(nodes.len(), 3);
368        assert_eq!(edges.len(), 2);
369    }
370
371    #[test]
372    fn test_compact() {
373        let mgr = GraphManager::new();
374        let a = MemoryId::new();
375        let b = MemoryId::new();
376        mgr.add_memory(a);
377        mgr.add_memory(b);
378        mgr.add_relationship(&make_edge(a, b, EdgeType::Caused))
379            .unwrap();
380        mgr.compact();
381
382        let out = mgr.graph().outgoing(a);
383        assert_eq!(out.len(), 1);
384    }
385
386    #[test]
387    fn test_edge_log_replay_without_snapshot() {
388        let dir = tempfile::tempdir().unwrap();
389        let a = MemoryId::new();
390        let b = MemoryId::new();
391        {
392            let mgr = GraphManager::open(dir.path()).unwrap();
393            mgr.add_memory(a);
394            mgr.add_memory(b);
395            mgr.add_relationship(&make_edge(a, b, EdgeType::Caused))
396                .unwrap();
397            // No save(): the mutations exist only in the edge log.
398        }
399        {
400            let mgr = GraphManager::open(dir.path()).unwrap();
401            assert!(mgr.read_graph().contains_node(a));
402            let out = mgr.read_graph().outgoing(a);
403            assert_eq!(out.len(), 1);
404            assert_eq!(out[0].0, b);
405        }
406    }
407
408    #[test]
409    fn test_save_truncates_edge_log() {
410        let dir = tempfile::tempdir().unwrap();
411        let a = MemoryId::new();
412        let b = MemoryId::new();
413        {
414            let mgr = GraphManager::open(dir.path()).unwrap();
415            mgr.add_memory(a);
416            mgr.add_memory(b);
417            mgr.add_relationship(&make_edge(a, b, EdgeType::Related))
418                .unwrap();
419            mgr.save(dir.path()).unwrap();
420            let log_len = std::fs::metadata(dir.path().join(super::EDGE_LOG_FILE))
421                .unwrap()
422                .len();
423            assert_eq!(log_len, 0, "save must truncate the edge log");
424        }
425        {
426            // Snapshot alone restores everything; further mutations keep logging.
427            let mgr = GraphManager::open(dir.path()).unwrap();
428            assert_eq!(mgr.read_graph().outgoing(a).len(), 1);
429            let c = MemoryId::new();
430            mgr.add_memory(c);
431            mgr.add_relationship(&make_edge(a, c, EdgeType::Supports))
432                .unwrap();
433        }
434        {
435            let mgr = GraphManager::open(dir.path()).unwrap();
436            assert_eq!(
437                mgr.read_graph().outgoing(a).len(),
438                2,
439                "post-save mutations must replay from the fresh log"
440            );
441        }
442    }
443
444    #[test]
445    fn test_belief_propagation() {
446        let mgr = GraphManager::new();
447        let a = MemoryId::new();
448        let b = MemoryId::new();
449        mgr.add_memory(a);
450        mgr.add_memory(b);
451        mgr.add_relationship(&MemoryEdge {
452            source: a,
453            target: b,
454            edge_type: EdgeType::Caused,
455            weight: 1.0,
456            created_at: 1000,
457            valid_from: None,
458            valid_until: None,
459            label: None,
460        })
461        .unwrap();
462
463        let results = mgr.propagate_belief_change(a, 0.5);
464        assert!(results.len() >= 2);
465    }
466}