1#[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 pub const MMAP_VERSION: u32 = 1;
29 pub const HEADER_SIZE: usize = 64;
31 pub const MAGIC_BYTES: &[u8; 8] = b"RBRNMAP1";
33
34 pub const FLAG_HAS_IDS: u32 = 1;
36
37 pub struct CsrCompiler;
39
40 impl CsrCompiler {
41 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 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 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 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 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 tmp.push(format!(".{}", std::process::id()));
172 PathBuf::from(tmp)
173 }
174
175 #[derive(Debug)]
177 pub struct CsrMmapGraph {
178 _file: File,
179 mmap: Mmap,
180 pub node_count: usize,
182 pub edge_count: usize,
184 pub vector_dim: usize,
186 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 node_ids: Vec<String>,
195 id_to_index: HashMap<String, u32>,
196 }
197
198 impl CsrMmapGraph {
199 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 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 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 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 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 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; 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 pub fn node_id(&self, idx: usize) -> Option<&str> {
335 self.node_ids.get(idx).map(|s| s.as_str())
336 }
337
338 pub fn index_of(&self, id: &str) -> Option<u32> {
340 self.id_to_index.get(id).copied()
341 }
342
343 pub fn node_ids(&self) -> &[String] {
345 &self.node_ids
346 }
347
348 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 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 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 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 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, 0.0, 1.0, 0.0, 0.0, 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::*;