Skip to main content

rustbrain_core/mmap/
mod.rs

1//! CSR graph mmap cache (format v1).
2//!
3//! The file `.brain/graph.mmap` is compiled from SQLite by [`CsrCompiler`] and
4//! read by [`CsrMmapGraph`]. It enables fast neighborhood expansion for agent
5//! context without re-querying SQLite for every hop.
6//!
7//! Full layout: repository `docs/MMAP_FORMAT.md`.
8//!
9//! # Design notes
10//!
11//! - All multi-byte integers are **little-endian**.
12//! - Sections are **size-validated** before any access.
13//! - Neighbor/vector accessors use explicit `from_le_bytes` loads (no unaligned
14//!   `from_raw_parts` casts) — correct on all platforms and free of UB under
15//!   mmap base-alignment variance.
16//! - Publish path is **write temp + `rename`** for crash safety.
17
18#[cfg(feature = "mmap")]
19mod imp {
20    use crate::error::{BrainError, Result};
21    use memmap2::Mmap;
22    use std::collections::HashMap;
23    use std::fs::File;
24    use std::io::Write;
25    use std::path::{Path, PathBuf};
26
27    /// On-disk format version for `graph.mmap`.
28    pub const MMAP_VERSION: u32 = 1;
29    /// Header size in bytes.
30    pub const HEADER_SIZE: usize = 64;
31    /// Magic bytes: `RBRNMAP1`
32    pub const MAGIC_BYTES: &[u8; 8] = b"RBRNMAP1";
33
34    /// Flag bit: id string table is present after the vector matrix.
35    pub const FLAG_HAS_IDS: u32 = 1;
36
37    /// Compiler that bakes nodes, edges, and optional vectors into `graph.mmap`.
38    pub struct CsrCompiler;
39
40    impl CsrCompiler {
41        /// Compile CSR graph and atomically replace `output_path` (write temp + rename).
42        pub fn compile<P: AsRef<Path>>(
43            output_path: P,
44            node_ids: &[String],
45            edges: &[(String, String, f32)],
46            vectors: Option<&[f32]>,
47            vector_dim: usize,
48        ) -> Result<()> {
49            let output_path = output_path.as_ref();
50            let bytes = Self::encode(node_ids, edges, vectors, vector_dim)?;
51
52            let parent = output_path.parent().unwrap_or_else(|| Path::new("."));
53            std::fs::create_dir_all(parent)?;
54
55            let tmp_path = unique_tmp_path(output_path);
56            {
57                let mut file = File::create(&tmp_path)?;
58                file.write_all(&bytes)?;
59                file.sync_all()?;
60            }
61            std::fs::rename(&tmp_path, output_path)?;
62            Ok(())
63        }
64
65        /// Encode the binary layout into a buffer (useful for tests).
66        pub fn encode(
67            node_ids: &[String],
68            edges: &[(String, String, f32)],
69            vectors: Option<&[f32]>,
70            vector_dim: usize,
71        ) -> Result<Vec<u8>> {
72            let node_count = node_ids.len();
73            if let Some(vecs) = vectors {
74                if vector_dim == 0 || vecs.len() != node_count * vector_dim {
75                    return Err(BrainError::mmap(
76                        "vector buffer length must equal node_count * vector_dim",
77                    ));
78                }
79            } else if vector_dim != 0 {
80                return Err(BrainError::mmap(
81                    "vector_dim must be 0 when vectors are absent",
82                ));
83            }
84
85            let mut id_to_index: HashMap<&str, u32> = HashMap::with_capacity(node_count);
86            for (idx, id) in node_ids.iter().enumerate() {
87                id_to_index.insert(id.as_str(), idx as u32);
88            }
89
90            let mut adj: Vec<Vec<(u32, f32)>> = vec![Vec::new(); node_count];
91            for (src, dst, weight) in edges {
92                if let (Some(&src_idx), Some(&dst_idx)) =
93                    (id_to_index.get(src.as_str()), id_to_index.get(dst.as_str()))
94                {
95                    adj[src_idx as usize].push((dst_idx, *weight));
96                }
97            }
98
99            let mut row_offsets: Vec<u32> = Vec::with_capacity(node_count + 1);
100            let mut targets: Vec<u32> = Vec::new();
101            let mut weights: Vec<f32> = Vec::new();
102            let mut current_offset = 0u32;
103            row_offsets.push(current_offset);
104            for neighbors in &adj {
105                for &(target_idx, w) in neighbors {
106                    targets.push(target_idx);
107                    weights.push(w);
108                    current_offset = current_offset.checked_add(1).ok_or_else(|| {
109                        BrainError::mmap("edge count overflow")
110                    })?;
111                }
112                row_offsets.push(current_offset);
113            }
114            let edge_count = targets.len();
115
116            // Estimate capacity.
117            let mut buf: Vec<u8> = Vec::with_capacity(
118                HEADER_SIZE
119                    + (node_count + 1) * 4
120                    + edge_count * 8
121                    + node_count * vector_dim * 4
122                    + node_ids.iter().map(|s| 2 + s.len()).sum::<usize>(),
123            );
124
125            // Header
126            let mut header = [0u8; HEADER_SIZE];
127            header[0..8].copy_from_slice(MAGIC_BYTES);
128            header[8..12].copy_from_slice(&MMAP_VERSION.to_le_bytes());
129            header[12..16].copy_from_slice(&(node_count as u32).to_le_bytes());
130            header[16..20].copy_from_slice(&(edge_count as u32).to_le_bytes());
131            header[20..24].copy_from_slice(&(vector_dim as u32).to_le_bytes());
132            header[24..28].copy_from_slice(&FLAG_HAS_IDS.to_le_bytes());
133            buf.extend_from_slice(&header);
134
135            for offset in &row_offsets {
136                buf.extend_from_slice(&offset.to_le_bytes());
137            }
138            for target in &targets {
139                buf.extend_from_slice(&target.to_le_bytes());
140            }
141            for w in &weights {
142                buf.extend_from_slice(&w.to_le_bytes());
143            }
144            if let Some(vecs) = vectors {
145                for v in vecs {
146                    buf.extend_from_slice(&v.to_le_bytes());
147                }
148            }
149
150            // ID string table (always written when FLAG_HAS_IDS is set).
151            for id in node_ids {
152                let bytes = id.as_bytes();
153                if bytes.len() > u16::MAX as usize {
154                    return Err(BrainError::mmap(format!(
155                        "node id longer than u16::MAX: {}",
156                        id
157                    )));
158                }
159                buf.extend_from_slice(&(bytes.len() as u16).to_le_bytes());
160                buf.extend_from_slice(bytes);
161            }
162
163            Ok(buf)
164        }
165    }
166
167    fn unique_tmp_path(output: &Path) -> PathBuf {
168        let mut tmp = output.as_os_str().to_os_string();
169        tmp.push(".tmp");
170        // Include pid to reduce collision under concurrent writers.
171        tmp.push(format!(".{}", std::process::id()));
172        PathBuf::from(tmp)
173    }
174
175    /// Memory-mapped CSR graph reader with safe unaligned loads.
176    #[derive(Debug)]
177    pub struct CsrMmapGraph {
178        _file: File,
179        mmap: Mmap,
180        /// Number of nodes in the CSR graph.
181        pub node_count: usize,
182        /// Number of edges.
183        pub edge_count: usize,
184        /// Embedding dimension (`0` when no vectors are stored).
185        pub vector_dim: usize,
186        /// Header flags bitfield.
187        pub flags: u32,
188        offsets_byte_start: usize,
189        targets_byte_start: usize,
190        weights_byte_start: usize,
191        vectors_byte_start: usize,
192        _ids_byte_start: usize,
193        /// Parsed once at open for O(1) id→index and index→id.
194        node_ids: Vec<String>,
195        id_to_index: HashMap<String, u32>,
196    }
197
198    impl CsrMmapGraph {
199        /// Open and fully validate a `graph.mmap` file.
200        pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
201            let file = File::open(path.as_ref())?;
202            let mmap = unsafe { Mmap::map(&file) }.map_err(|e| {
203                BrainError::mmap(format!("failed to memory-map graph file: {e}"))
204            })?;
205            Self::from_bytes(file, mmap)
206        }
207
208        fn from_bytes(file: File, mmap: Mmap) -> Result<Self> {
209            if mmap.len() < HEADER_SIZE {
210                return Err(BrainError::mmap("file too small for header"));
211            }
212            if &mmap[0..8] != MAGIC_BYTES {
213                return Err(BrainError::mmap(format!(
214                    "invalid magic bytes (found {:?})",
215                    String::from_utf8_lossy(&mmap[0..8])
216                )));
217            }
218
219            let version = read_u32(&mmap, 8)?;
220            if version != MMAP_VERSION {
221                return Err(BrainError::mmap(format!(
222                    "unsupported mmap version {version} (expected {MMAP_VERSION})"
223                )));
224            }
225
226            let node_count = read_u32(&mmap, 12)? as usize;
227            let edge_count = read_u32(&mmap, 16)? as usize;
228            let vector_dim = read_u32(&mmap, 20)? as usize;
229            let flags = read_u32(&mmap, 24)?;
230
231            let offsets_byte_start = HEADER_SIZE;
232            let targets_byte_start = offsets_byte_start
233                .checked_add(node_count.checked_add(1).ok_or_else(|| BrainError::mmap("overflow"))? * 4)
234                .ok_or_else(|| BrainError::mmap("overflow"))?;
235            let weights_byte_start = targets_byte_start
236                .checked_add(edge_count * 4)
237                .ok_or_else(|| BrainError::mmap("overflow"))?;
238            let vectors_byte_start = weights_byte_start
239                .checked_add(edge_count * 4)
240                .ok_or_else(|| BrainError::mmap("overflow"))?;
241            let ids_byte_start = vectors_byte_start
242                .checked_add(node_count * vector_dim * 4)
243                .ok_or_else(|| BrainError::mmap("overflow"))?;
244
245            // Minimum size without IDs.
246            if mmap.len() < ids_byte_start {
247                return Err(BrainError::mmap(format!(
248                    "truncated file: need at least {ids_byte_start} bytes, have {}",
249                    mmap.len()
250                )));
251            }
252
253            let mut node_ids = Vec::with_capacity(node_count);
254            let mut id_to_index = HashMap::with_capacity(node_count);
255            let mut cursor = ids_byte_start;
256
257            if flags & FLAG_HAS_IDS != 0 {
258                for i in 0..node_count {
259                    if cursor + 2 > mmap.len() {
260                        return Err(BrainError::mmap("truncated id table (length prefix)"));
261                    }
262                    let len = read_u16(&mmap, cursor)? as usize;
263                    cursor += 2;
264                    if cursor + len > mmap.len() {
265                        return Err(BrainError::mmap("truncated id table (payload)"));
266                    }
267                    let s = std::str::from_utf8(&mmap[cursor..cursor + len])
268                        .map_err(|e| BrainError::mmap(format!("invalid utf-8 in id table: {e}")))?
269                        .to_string();
270                    cursor += len;
271                    id_to_index.insert(s.clone(), i as u32);
272                    node_ids.push(s);
273                }
274            } else {
275                // Synthesize synthetic ids if absent (legacy/test).
276                for i in 0..node_count {
277                    let s = format!("node-{i}");
278                    id_to_index.insert(s.clone(), i as u32);
279                    node_ids.push(s);
280                }
281            }
282
283            // Validate CSR offsets monotonicity and bounds.
284            if node_count > 0 {
285                let mut prev = 0u32;
286                for i in 0..=node_count {
287                    let off = read_u32(&mmap, offsets_byte_start + i * 4)?;
288                    if off < prev || off as usize > edge_count {
289                        return Err(BrainError::mmap(format!(
290                            "invalid row offset at {i}: {off} (prev={prev}, edge_count={edge_count})"
291                        )));
292                    }
293                    prev = off;
294                }
295                // Final offset must equal edge_count.
296                let last = read_u32(&mmap, offsets_byte_start + node_count * 4)?;
297                if last as usize != edge_count {
298                    return Err(BrainError::mmap(format!(
299                        "final row offset {last} != edge_count {edge_count}"
300                    )));
301                }
302            }
303
304            // Validate target indices.
305            for i in 0..edge_count {
306                let t = read_u32(&mmap, targets_byte_start + i * 4)? as usize;
307                if t >= node_count {
308                    return Err(BrainError::mmap(format!(
309                        "edge target {t} out of range (node_count={node_count})"
310                    )));
311                }
312            }
313
314            let _ = cursor; // remaining trailing bytes ignored (forward compatible)
315
316            Ok(Self {
317                _file: file,
318                mmap,
319                node_count,
320                edge_count,
321                vector_dim,
322                flags,
323                offsets_byte_start,
324                targets_byte_start,
325                weights_byte_start,
326                vectors_byte_start,
327                _ids_byte_start: ids_byte_start,
328                node_ids,
329                id_to_index,
330            })
331        }
332
333        /// Look up the node id string for a CSR index.
334        pub fn node_id(&self, idx: usize) -> Option<&str> {
335            self.node_ids.get(idx).map(|s| s.as_str())
336        }
337
338        /// Look up the CSR index for a node id.
339        pub fn index_of(&self, id: &str) -> Option<u32> {
340            self.id_to_index.get(id).copied()
341        }
342
343        /// All node ids in CSR index order.
344        pub fn node_ids(&self) -> &[String] {
345            &self.node_ids
346        }
347
348        /// Graph neighbors as owned vectors (safe, allocation per call).
349        pub fn get_neighbors(&self, node_idx: usize) -> (Vec<u32>, Vec<f32>) {
350            if node_idx >= self.node_count {
351                return (Vec::new(), Vec::new());
352            }
353            let start = read_u32(&self.mmap, self.offsets_byte_start + node_idx * 4)
354                .unwrap_or(0) as usize;
355            let end = read_u32(&self.mmap, self.offsets_byte_start + (node_idx + 1) * 4)
356                .unwrap_or(0) as usize;
357            if start > end || end > self.edge_count {
358                return (Vec::new(), Vec::new());
359            }
360            let mut targets = Vec::with_capacity(end - start);
361            let mut weights = Vec::with_capacity(end - start);
362            for i in start..end {
363                let t = read_u32(&self.mmap, self.targets_byte_start + i * 4).unwrap_or(0);
364                let w = read_f32(&self.mmap, self.weights_byte_start + i * 4).unwrap_or(0.0);
365                targets.push(t);
366                weights.push(w);
367            }
368            (targets, weights)
369        }
370
371        /// k-hop neighborhood with multiplicative path weights (first visit wins).
372        pub fn k_hop_neighborhood(&self, start_node_idx: usize, k: usize) -> Vec<(u32, f32)> {
373            let mut visited: HashMap<u32, f32> = HashMap::new();
374            let mut current_frontier = vec![(start_node_idx as u32, 1.0f32)];
375
376            for _ in 0..k {
377                let mut next_frontier = Vec::new();
378                for (curr_node, curr_w) in current_frontier {
379                    let (targets, weights) = self.get_neighbors(curr_node as usize);
380                    for i in 0..targets.len() {
381                        let target = targets[i];
382                        if target as usize == start_node_idx {
383                            continue;
384                        }
385                        let weight = weights[i] * curr_w;
386                        if let std::collections::hash_map::Entry::Vacant(e) =
387                            visited.entry(target)
388                        {
389                            e.insert(weight);
390                            next_frontier.push((target, weight));
391                        }
392                    }
393                }
394                current_frontier = next_frontier;
395            }
396
397            visited.into_iter().collect()
398        }
399
400        /// Copy out the vector row for `node_idx` (None if no vectors).
401        pub fn get_vector(&self, node_idx: usize) -> Option<Vec<f32>> {
402            if self.vector_dim == 0 || node_idx >= self.node_count {
403                return None;
404            }
405            let start = self.vectors_byte_start + node_idx * self.vector_dim * 4;
406            let mut out = Vec::with_capacity(self.vector_dim);
407            for i in 0..self.vector_dim {
408                out.push(read_f32(&self.mmap, start + i * 4).ok()?);
409            }
410            Some(out)
411        }
412
413        /// Dot product with an unrolled loop (may auto-vectorize; not explicit SIMD).
414        pub fn dot_product(a: &[f32], b: &[f32]) -> f32 {
415            let len = a.len().min(b.len());
416            let mut sum = 0.0f32;
417            let chunks_a = a[..len].chunks_exact(8);
418            let chunks_b = b[..len].chunks_exact(8);
419            let remainder_a = chunks_a.remainder();
420            let remainder_b = chunks_b.remainder();
421            for (ca, cb) in chunks_a.zip(chunks_b) {
422                sum += ca[0] * cb[0]
423                    + ca[1] * cb[1]
424                    + ca[2] * cb[2]
425                    + ca[3] * cb[3]
426                    + ca[4] * cb[4]
427                    + ca[5] * cb[5]
428                    + ca[6] * cb[6]
429                    + ca[7] * cb[7];
430            }
431            for (ra, rb) in remainder_a.iter().zip(remainder_b.iter()) {
432                sum += ra * rb;
433            }
434            sum
435        }
436
437        /// Full scan top-k by dot product (acceptable for small N in v0.1).
438        pub fn top_k_vector_search(&self, query_vector: &[f32], top_k: usize) -> Vec<(u32, f32)> {
439            if self.vector_dim == 0 || query_vector.len() != self.vector_dim {
440                return Vec::new();
441            }
442            let mut scores: Vec<(u32, f32)> = Vec::with_capacity(self.node_count);
443            for idx in 0..self.node_count {
444                if let Some(vec_row) = self.get_vector(idx) {
445                    let score = Self::dot_product(query_vector, &vec_row);
446                    scores.push((idx as u32, score));
447                }
448            }
449            scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
450            scores.truncate(top_k);
451            scores
452        }
453    }
454
455    fn read_u16(buf: &[u8], off: usize) -> Result<u16> {
456        let end = off.checked_add(2).ok_or_else(|| BrainError::mmap("overflow"))?;
457        if end > buf.len() {
458            return Err(BrainError::mmap("read_u16 out of bounds"));
459        }
460        Ok(u16::from_le_bytes(buf[off..end].try_into().unwrap()))
461    }
462
463    fn read_u32(buf: &[u8], off: usize) -> Result<u32> {
464        let end = off.checked_add(4).ok_or_else(|| BrainError::mmap("overflow"))?;
465        if end > buf.len() {
466            return Err(BrainError::mmap("read_u32 out of bounds"));
467        }
468        Ok(u32::from_le_bytes(buf[off..end].try_into().unwrap()))
469    }
470
471    fn read_f32(buf: &[u8], off: usize) -> Result<f32> {
472        let end = off.checked_add(4).ok_or_else(|| BrainError::mmap("overflow"))?;
473        if end > buf.len() {
474            return Err(BrainError::mmap("read_f32 out of bounds"));
475        }
476        Ok(f32::from_le_bytes(buf[off..end].try_into().unwrap()))
477    }
478
479    #[cfg(test)]
480    mod tests {
481        use super::*;
482        use tempfile::tempdir;
483
484        #[test]
485        fn compile_open_query() {
486            let dir = tempdir().unwrap();
487            let mmap_path = dir.path().join("graph.mmap");
488
489            let node_ids = vec![
490                "docs/a".to_string(),
491                "docs/b".to_string(),
492                "docs/c".to_string(),
493            ];
494            let edges = vec![
495                ("docs/a".to_string(), "docs/b".to_string(), 0.9f32),
496                ("docs/a".to_string(), "docs/c".to_string(), 0.5f32),
497                ("docs/b".to_string(), "docs/c".to_string(), 0.8f32),
498            ];
499            let vectors = vec![
500                1.0, 0.0, 0.0, 0.0, //
501                0.0, 1.0, 0.0, 0.0, //
502                1.0, 1.0, 0.0, 0.0,
503            ];
504
505            CsrCompiler::compile(&mmap_path, &node_ids, &edges, Some(&vectors), 4).unwrap();
506            let graph = CsrMmapGraph::open(&mmap_path).unwrap();
507            assert_eq!(graph.node_count, 3);
508            assert_eq!(graph.edge_count, 3);
509            assert_eq!(graph.vector_dim, 4);
510            assert_eq!(graph.node_id(0), Some("docs/a"));
511            assert_eq!(graph.index_of("docs/b"), Some(1));
512
513            let (targets, weights) = graph.get_neighbors(0);
514            assert_eq!(targets, vec![1, 2]);
515            assert!((weights[0] - 0.9).abs() < 1e-6);
516
517            let neighbors = graph.k_hop_neighborhood(0, 1);
518            assert_eq!(neighbors.len(), 2);
519
520            let top = graph.top_k_vector_search(&[1.0, 0.0, 0.0, 0.0], 2);
521            assert_eq!(top[0].0, 0);
522            assert!((top[0].1 - 1.0).abs() < 1e-6);
523        }
524
525        #[test]
526        fn rejects_truncated_file() {
527            let dir = tempdir().unwrap();
528            let path = dir.path().join("bad.mmap");
529            std::fs::write(&path, b"RBRNMAP1").unwrap();
530            let err = CsrMmapGraph::open(&path).unwrap_err();
531            assert!(matches!(err, BrainError::MmapFormat(_)));
532        }
533
534        #[test]
535        fn rejects_bad_magic() {
536            let dir = tempdir().unwrap();
537            let path = dir.path().join("bad.mmap");
538            let mut buf = vec![0u8; 64];
539            buf[0..8].copy_from_slice(b"NOTRIGHT");
540            std::fs::write(&path, buf).unwrap();
541            assert!(CsrMmapGraph::open(&path).is_err());
542        }
543
544        #[test]
545        fn atomic_replace_leaves_valid_file() {
546            let dir = tempdir().unwrap();
547            let path = dir.path().join("graph.mmap");
548            let ids = vec!["n0".into()];
549            CsrCompiler::compile(&path, &ids, &[], None, 0).unwrap();
550            CsrCompiler::compile(&path, &ids, &[], None, 0).unwrap();
551            let g = CsrMmapGraph::open(&path).unwrap();
552            assert_eq!(g.node_count, 1);
553        }
554    }
555}
556
557#[cfg(feature = "mmap")]
558pub use imp::*;
559
560#[cfg(not(feature = "mmap"))]
561mod stub {
562    use crate::error::{BrainError, Result};
563    use std::path::Path;
564
565    pub const MMAP_VERSION: u32 = 1;
566    pub const HEADER_SIZE: usize = 64;
567    pub const MAGIC_BYTES: &[u8; 8] = b"RBRNMAP1";
568
569    pub struct CsrCompiler;
570    impl CsrCompiler {
571        pub fn compile<P: AsRef<Path>>(
572            _output_path: P,
573            _node_ids: &[String],
574            _edges: &[(String, String, f32)],
575            _vectors: Option<&[f32]>,
576            _vector_dim: usize,
577        ) -> Result<()> {
578            Err(BrainError::FeatureDisabled("mmap"))
579        }
580    }
581
582    pub struct CsrMmapGraph;
583    impl CsrMmapGraph {
584        pub fn open<P: AsRef<Path>>(_path: P) -> Result<Self> {
585            Err(BrainError::FeatureDisabled("mmap"))
586        }
587    }
588}
589
590#[cfg(not(feature = "mmap"))]
591pub use stub::*;