1use std::collections::HashMap;
41use std::path::Path;
42
43use memmap2::Mmap;
44
45use crate::index::{run_search, SparseVector};
46use crate::wand::{MmapCursor, Postings};
47
48const MAGIC: u32 = 0x53505253; const FORMAT_VERSION: u32 = 3;
52const GLOBAL_DIMS_VERSION: u32 = 3;
55const MIN_READABLE_VERSION: u32 = 1;
57const FOOTER_SIZE: usize = 8;
59
60#[repr(C)]
61struct FileHeader {
62 magic: u32,
63 version: u32,
64 num_dims: u32,
65 num_vectors: u32,
66}
67
68#[repr(C)]
69struct DimHeader {
70 offset: u64,
71 count: u32,
72 token_id: u32,
75}
76
77#[repr(C)]
79#[derive(Clone, Copy, Debug)]
80pub struct PostingEntry {
81 pub record_id: u64,
82 pub weight: f32,
83 pub max_next_weight: f32,
85}
86
87pub struct MmapPostingData {
89 mmap: Mmap,
90 num_dims: usize,
91 num_vectors: usize,
92 version: u32,
94}
95
96impl MmapPostingData {
97 pub fn open(path: &Path) -> Result<Self, String> {
98 let file = std::fs::File::open(path)
99 .map_err(|e| format!("cannot open {}: {e}", path.display()))?;
100 let mmap = unsafe { Mmap::map(&file) }
101 .map_err(|e| format!("cannot mmap {}: {e}", path.display()))?;
102
103 if mmap.len() < std::mem::size_of::<FileHeader>() {
104 return Err("sparse.mmap too small for header".into());
105 }
106 let header = unsafe { &*(mmap.as_ptr() as *const FileHeader) };
107 if header.magic != MAGIC {
108 return Err(format!("bad magic: {:#x}", header.magic));
109 }
110 if header.version < MIN_READABLE_VERSION || header.version > FORMAT_VERSION {
111 return Err(format!("unsupported version: {} (this build reads {MIN_READABLE_VERSION}..={FORMAT_VERSION})",
112 header.version));
113 }
114 let versioned = Self {
115 mmap,
116 num_dims: header.num_dims as usize,
117 num_vectors: header.num_vectors as usize,
118 version: header.version,
119 };
120 versioned.check_length(path)?;
124 if std::env::var("LUCIVY_SPARSE_VERIFY_CRC").is_ok_and(|v| v != "0") {
125 versioned.verify_checksum(path)?;
126 }
127 Ok(versioned)
128 }
129
130 fn expected_len(&self) -> usize {
132 let dim_headers = std::mem::size_of::<FileHeader>()
133 + self.num_dims * std::mem::size_of::<DimHeader>();
134 let entries: usize = (0..self.num_dims)
135 .map(|i| {
136 let ptr = unsafe {
137 self.mmap.as_ptr().add(
138 std::mem::size_of::<FileHeader>() + i * std::mem::size_of::<DimHeader>(),
139 ) as *const DimHeader
140 };
141 unsafe { (*ptr).count as usize }
142 })
143 .sum();
144 dim_headers + entries * std::mem::size_of::<PostingEntry>()
145 + if self.version >= 2 { FOOTER_SIZE } else { 0 }
146 }
147
148 fn check_length(&self, path: &Path) -> Result<(), String> {
149 let headers_end = std::mem::size_of::<FileHeader>()
151 + self.num_dims * std::mem::size_of::<DimHeader>();
152 if self.mmap.len() < headers_end {
153 return Err(format!(
154 "{}: truncated — {} bytes for {} dimension headers",
155 path.display(), self.mmap.len(), self.num_dims,
156 ));
157 }
158 let expected = self.expected_len();
159 if self.mmap.len() != expected {
160 return Err(format!(
161 "{}: truncated or corrupt — {} bytes, its headers describe {expected}",
162 path.display(), self.mmap.len(),
163 ));
164 }
165 Ok(())
166 }
167
168 pub fn verify_checksum(&self, path: &Path) -> Result<(), String> {
171 if self.version < 2 {
172 return Ok(());
173 }
174 let len = self.mmap.len();
175 let body = &self.mmap[..len - FOOTER_SIZE];
176 let stored = u32::from_le_bytes(self.mmap[len - FOOTER_SIZE..len - 4].try_into().unwrap());
177 let magic = u32::from_le_bytes(self.mmap[len - 4..].try_into().unwrap());
178 if magic != MAGIC {
179 return Err(format!("{}: footer magic is {magic:#x}", path.display()));
180 }
181 let mut hasher = crc32fast::Hasher::new();
182 hasher.update(body);
183 let actual = hasher.finalize();
184 if actual != stored {
185 return Err(format!("{}: checksum mismatch — {actual:#x}, the file says {stored:#x}",
186 path.display()));
187 }
188 Ok(())
189 }
190
191 pub fn num_dims(&self) -> usize {
192 self.num_dims
193 }
194
195 pub fn num_vectors(&self) -> usize {
196 self.num_vectors
197 }
198
199 pub fn version(&self) -> u32 {
201 self.version
202 }
203
204 pub fn has_global_dims(&self) -> bool {
207 self.version >= GLOBAL_DIMS_VERSION
208 }
209
210 fn dim_headers(&self) -> &[DimHeader] {
213 let ptr = unsafe { self.mmap.as_ptr().add(std::mem::size_of::<FileHeader>()) }
214 as *const DimHeader;
215 unsafe { std::slice::from_raw_parts(ptr, self.num_dims) }
216 }
217
218 pub fn dim_of_token(&self, token_id: u32) -> Option<usize> {
222 if !self.has_global_dims() {
223 return None;
224 }
225 self.dim_headers()
226 .binary_search_by_key(&token_id, |dh| dh.token_id)
227 .ok()
228 }
229
230 pub fn tokens(&self) -> impl Iterator<Item = (u32, usize)> + '_ {
233 let global = self.has_global_dims();
234 self.dim_headers().iter().enumerate()
235 .filter(move |_| global)
236 .map(|(i, dh)| (dh.token_id, i))
237 }
238
239 pub fn entries_of_token(&self, token_id: u32) -> &[PostingEntry] {
242 match self.dim_of_token(token_id) {
243 Some(i) => self.entries(i),
244 None => &[],
245 }
246 }
247
248 pub fn entries(&self, dim_idx: usize) -> &[PostingEntry] {
251 if dim_idx >= self.num_dims {
252 return &[];
253 }
254 let dim_headers_offset = std::mem::size_of::<FileHeader>();
255 let dh_ptr = unsafe {
256 self.mmap
257 .as_ptr()
258 .add(dim_headers_offset + dim_idx * std::mem::size_of::<DimHeader>())
259 } as *const DimHeader;
260 let dh = unsafe { &*dh_ptr };
261
262 if dh.count == 0 {
263 return &[];
264 }
265
266 let entries_ptr =
267 unsafe { self.mmap.as_ptr().add(dh.offset as usize) } as *const PostingEntry;
268 unsafe { std::slice::from_raw_parts(entries_ptr, dh.count as usize) }
269 }
270
271 pub fn cursor(&self, dim_idx: usize) -> Option<MmapCursor<'_>> {
273 MmapCursor::open(self, dim_idx as u32)
274 }
275
276 pub fn load_postings_of_token(&self, token_id: u32) -> Postings {
281 match self.dim_of_token(token_id) {
282 Some(i) => self.load_postings(i),
283 None => Postings::new(),
284 }
285 }
286
287 pub fn load_postings(&self, dim_idx: usize) -> Postings {
291 let pairs: Vec<(u64, f32)> = self
292 .entries(dim_idx)
293 .iter()
294 .map(|e| (e.record_id, e.weight))
295 .collect();
296 Postings::from_sorted_pairs(&pairs)
297 }
298}
299
300fn dims_for<'a>(
309 mmap: &'a MmapPostingData,
310 dim_map: &'a HashMap<u32, usize>,
311 query: &SparseVector,
312) -> HashMap<u32, usize> {
313 if !mmap.has_global_dims() {
314 return query.indices.iter()
315 .filter_map(|t| dim_map.get(t).map(|&d| (*t, d)))
316 .collect();
317 }
318 query.indices.iter()
319 .filter_map(|&t| mmap.dim_of_token(t).map(|d| (t, d)))
320 .collect()
321}
322
323pub fn search_mmap<F: Fn(u64) -> bool>(
327 mmap: &MmapPostingData,
328 dim_map: &HashMap<u32, usize>,
329 query: &SparseVector,
330 limit: usize,
331 filter: &F,
332) -> Vec<(u64, f32)> {
333 if query.is_empty() || mmap.num_vectors() == 0 {
334 return Vec::new();
335 }
336 let dims = dims_for(mmap, dim_map, query);
337 run_search(query, &dims, limit, filter, |dim| mmap.cursor(dim as usize))
338}
339
340pub fn search_mmap_allowed(
343 mmap: &MmapPostingData,
344 dim_map: &HashMap<u32, usize>,
345 query: &SparseVector,
346 limit: usize,
347 allowed: &[u64],
348) -> Vec<(u64, f32)> {
349 if query.is_empty() || mmap.num_vectors() == 0 {
350 return Vec::new();
351 }
352 let dims = dims_for(mmap, dim_map, query);
353 crate::index::run_search_allowed(query, &dims, limit, allowed, |dim| mmap.cursor(dim as usize))
354}
355
356pub fn write_mmap_file(
368 path: &Path,
369 postings: &[Postings],
370 dim_tokens: &[u32],
371 num_vectors: u32,
372) -> Result<(), String> {
373 use std::io::Write;
374
375 if dim_tokens.len() != postings.len() {
376 return Err(format!(
377 "{} posting lists for {} dimension ids", postings.len(), dim_tokens.len()));
378 }
379 let mut order: Vec<usize> = (0..postings.len()).filter(|&i| !postings[i].is_empty()).collect();
381 order.sort_by_key(|&i| dim_tokens[i]);
382 let num_dims = order.len() as u32;
383 let header_size = std::mem::size_of::<FileHeader>();
384 let dim_headers_size = num_dims as usize * std::mem::size_of::<DimHeader>();
385 let entries_start = header_size + dim_headers_size;
386
387 write_atomic(path, |file| {
388 let mut out = CrcWriter { inner: file, hasher: crc32fast::Hasher::new() };
389 let out = &mut out;
390 let header = FileHeader {
391 magic: MAGIC,
392 version: FORMAT_VERSION,
393 num_dims,
394 num_vectors,
395 };
396 out.write_all(as_bytes(&header))
397 .map_err(|e| format!("write header: {e}"))?;
398
399 let mut current_offset = entries_start;
400 for &i in &order {
401 let dh = DimHeader {
402 offset: current_offset as u64,
403 count: postings[i].len() as u32,
404 token_id: dim_tokens[i],
405 };
406 out.write_all(as_bytes(&dh))
407 .map_err(|e| format!("write dim header: {e}"))?;
408 current_offset += postings[i].len() * std::mem::size_of::<PostingEntry>();
409 }
410
411 for &i in &order {
412 let p = &postings[i];
413 for x in p.as_slice() {
414 let entry = PostingEntry {
415 record_id: x.id,
416 weight: x.weight,
417 max_next_weight: x.tail_max,
418 };
419 out.write_all(as_bytes(&entry))
420 .map_err(|e| format!("write entry: {e}"))?;
421 }
422 }
423 let crc = out.hasher.clone().finalize();
425 out.inner.write_all(&crc.to_le_bytes()).map_err(|e| format!("write checksum: {e}"))?;
426 out.inner.write_all(&MAGIC.to_le_bytes()).map_err(|e| format!("write footer magic: {e}"))?;
427 Ok(())
428 })
429}
430
431struct CrcWriter<'a> {
434 inner: &'a mut std::io::BufWriter<std::fs::File>,
435 hasher: crc32fast::Hasher,
436}
437
438impl std::io::Write for CrcWriter<'_> {
439 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
440 let n = self.inner.write(buf)?;
441 self.hasher.update(&buf[..n]);
442 Ok(n)
443 }
444 fn flush(&mut self) -> std::io::Result<()> {
445 self.inner.flush()
446 }
447}
448
449fn write_atomic(
454 path: &Path,
455 body: impl FnOnce(&mut std::io::BufWriter<std::fs::File>) -> Result<(), String>,
456) -> Result<(), String> {
457 use std::io::Write;
458
459 let name = path.file_name().ok_or_else(|| format!("{}: no file name", path.display()))?;
460 let tmp = path.with_file_name(format!("{}.tmp", name.to_string_lossy()));
461 let file = std::fs::File::create(&tmp)
462 .map_err(|e| format!("cannot create {}: {e}", tmp.display()))?;
463 let mut out = std::io::BufWriter::new(file);
464
465 let written = body(&mut out).and_then(|()| {
466 out.flush().map_err(|e| format!("flush {}: {e}", tmp.display()))?;
467 out.get_ref().sync_all().map_err(|e| format!("sync {}: {e}", tmp.display()))
468 });
469 drop(out);
470 if let Err(e) = written {
471 let _ = std::fs::remove_file(&tmp);
472 return Err(e);
473 }
474 std::fs::rename(&tmp, path)
475 .map_err(|e| format!("cannot rename {} onto {}: {e}", tmp.display(), path.display()))?;
476 if let Some(dir) = path.parent() {
480 if let Ok(d) = std::fs::File::open(dir) {
481 let _ = d.sync_all();
482 }
483 }
484 Ok(())
485}
486
487pub fn write_file_atomic(path: &Path, data: &[u8]) -> Result<(), String> {
492 use std::io::Write;
493 write_atomic(path, |out| out.write_all(data).map_err(|e| format!("write {}: {e}", path.display())))
494}
495
496fn as_bytes<T: Sized>(val: &T) -> &[u8] {
498 unsafe { std::slice::from_raw_parts(val as *const T as *const u8, std::mem::size_of::<T>()) }
499}