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