Skip to main content

astraea_vector/
persistence.rs

1//! Persistence layer for HNSW indices.
2//!
3//! Provides save/load functionality using a versioned binary file format.
4//! The format uses a fixed header for quick validation followed by bincode-
5//! serialized index data for compact storage of float arrays and adjacency lists.
6
7use std::fs::File;
8use std::io::{BufReader, BufWriter, Read, Write};
9use std::path::Path;
10
11use astraea_core::error::{AstraeaError, Result};
12use astraea_core::types::DistanceMetric;
13use bincode::Options as _;
14
15use crate::hnsw::HnswIndex;
16
17/// Magic bytes identifying an HNSW index file: ASCII "HNSW".
18const MAGIC: u32 = 0x48_4E_53_57;
19
20/// Current file format version.
21const FORMAT_VERSION: u32 = 1;
22
23/// Maximum total file size accepted by any load entry point.
24///
25/// Files larger than this cap are rejected before any deserialization.
26/// This prevents a corrupt or adversarial `.hnsw` file from causing an
27/// allocator abort (OOM kill) through an inflated bincode collection-count
28/// field.  The limit is intentionally generous: 4 GiB accommodates roughly
29/// 100 million 768-dimensional f32 vectors plus graph adjacency overhead.
30const MAX_HNSW_BYTES: u64 = 4 * 1024 * 1024 * 1024;
31
32/// Byte length of the fixed on-disk header written by [`write_header`].
33///
34/// magic(4) + version(4) + dimension(4) + metric(1) + m(4) + m_max0(4)
35/// + ef_construction(4) + num_vectors(8) + num_layers(4) = 37 bytes.
36const HEADER_SIZE: u64 = 37;
37
38/// Fixed-size header written at the start of every HNSW index file.
39///
40/// This allows quick validation and metadata inspection without
41/// deserializing the full index.
42#[derive(Debug, Clone, Copy)]
43#[repr(C)]
44struct HnswFileHeader {
45    /// Magic bytes for file identification.
46    magic: u32,
47    /// Format version for forward compatibility.
48    version: u32,
49    /// Vector dimensionality.
50    dimension: u32,
51    /// Distance metric: 0=Cosine, 1=Euclidean, 2=DotProduct.
52    metric: u8,
53    /// Max connections per node per layer (except layer 0).
54    m: u32,
55    /// Max connections at layer 0.
56    m_max0: u32,
57    /// Beam width during construction.
58    ef_construction: u32,
59    /// Number of vectors stored.
60    num_vectors: u64,
61    /// Number of layers in the graph.
62    num_layers: u32,
63}
64
65/// Encode a `DistanceMetric` as a single byte for the file header.
66fn metric_to_byte(metric: DistanceMetric) -> u8 {
67    match metric {
68        DistanceMetric::Cosine => 0,
69        DistanceMetric::Euclidean => 1,
70        DistanceMetric::DotProduct => 2,
71    }
72}
73
74/// Decode a single byte from the file header into a `DistanceMetric`.
75fn byte_to_metric(b: u8) -> Result<DistanceMetric> {
76    match b {
77        0 => Ok(DistanceMetric::Cosine),
78        1 => Ok(DistanceMetric::Euclidean),
79        2 => Ok(DistanceMetric::DotProduct),
80        _ => Err(AstraeaError::Deserialization(format!(
81            "unknown distance metric byte: {b}"
82        ))),
83    }
84}
85
86/// Write the fixed header to the given writer.
87fn write_header<W: Write>(writer: &mut W, header: &HnswFileHeader) -> Result<()> {
88    writer.write_all(&header.magic.to_le_bytes())?;
89    writer.write_all(&header.version.to_le_bytes())?;
90    writer.write_all(&header.dimension.to_le_bytes())?;
91    writer.write_all(&[header.metric])?;
92    writer.write_all(&header.m.to_le_bytes())?;
93    writer.write_all(&header.m_max0.to_le_bytes())?;
94    writer.write_all(&header.ef_construction.to_le_bytes())?;
95    writer.write_all(&header.num_vectors.to_le_bytes())?;
96    writer.write_all(&header.num_layers.to_le_bytes())?;
97    Ok(())
98}
99
100/// Read the fixed header from the given reader and validate magic/version.
101fn read_header<R: Read>(reader: &mut R) -> Result<HnswFileHeader> {
102    let mut buf4 = [0u8; 4];
103    let mut buf8 = [0u8; 8];
104    let mut buf1 = [0u8; 1];
105
106    // magic
107    reader.read_exact(&mut buf4)?;
108    let magic = u32::from_le_bytes(buf4);
109    if magic != MAGIC {
110        return Err(AstraeaError::Deserialization(format!(
111            "invalid HNSW file magic: expected 0x{MAGIC:08X}, got 0x{magic:08X}"
112        )));
113    }
114
115    // version
116    reader.read_exact(&mut buf4)?;
117    let version = u32::from_le_bytes(buf4);
118    if version != FORMAT_VERSION {
119        return Err(AstraeaError::Deserialization(format!(
120            "unsupported HNSW file version: expected {FORMAT_VERSION}, got {version}"
121        )));
122    }
123
124    // dimension
125    reader.read_exact(&mut buf4)?;
126    let dimension = u32::from_le_bytes(buf4);
127
128    // metric
129    reader.read_exact(&mut buf1)?;
130    let metric = buf1[0];
131
132    // m
133    reader.read_exact(&mut buf4)?;
134    let m = u32::from_le_bytes(buf4);
135
136    // m_max0
137    reader.read_exact(&mut buf4)?;
138    let m_max0 = u32::from_le_bytes(buf4);
139
140    // ef_construction
141    reader.read_exact(&mut buf4)?;
142    let ef_construction = u32::from_le_bytes(buf4);
143
144    // num_vectors
145    reader.read_exact(&mut buf8)?;
146    let num_vectors = u64::from_le_bytes(buf8);
147
148    // num_layers
149    reader.read_exact(&mut buf4)?;
150    let num_layers = u32::from_le_bytes(buf4);
151
152    Ok(HnswFileHeader {
153        magic,
154        version,
155        dimension,
156        metric,
157        m,
158        m_max0,
159        ef_construction,
160        num_vectors,
161        num_layers,
162    })
163}
164
165/// Save an `HnswIndex` to the file at `path`.
166///
167/// The file format is:
168/// 1. Fixed header (magic, version, metadata)
169/// 2. Bincode-serialized index body (vectors, layers, entry_point, etc.)
170///
171/// Returns `AstraeaError::Serialization` if the index dimension exceeds
172/// `u32::MAX` and therefore cannot be represented in the on-disk header.
173pub fn save_to_file(index: &HnswIndex, path: &Path) -> Result<()> {
174    let file = File::create(path)?;
175    let mut writer = BufWriter::new(file);
176
177    let dimension_u32 = u32::try_from(index.dimension()).map_err(|_| {
178        AstraeaError::Serialization(format!(
179            "index dimension {} exceeds u32::MAX and cannot be written to the HNSW file header",
180            index.dimension()
181        ))
182    })?;
183
184    let header = HnswFileHeader {
185        magic: MAGIC,
186        version: FORMAT_VERSION,
187        dimension: dimension_u32,
188        metric: metric_to_byte(index.metric()),
189        m: index.m() as u32,
190        m_max0: index.m_max0() as u32,
191        ef_construction: index.ef_construction() as u32,
192        num_vectors: index.len() as u64,
193        num_layers: index.num_layers() as u32,
194    };
195
196    write_header(&mut writer, &header)?;
197
198    // Serialize the full index via bincode.
199    bincode::serialize_into(&mut writer, index)
200        .map_err(|e| AstraeaError::Serialization(format!("bincode serialization failed: {e}")))?;
201
202    writer.flush()?;
203    Ok(())
204}
205
206/// Load an `HnswIndex` from the file at `path`.
207///
208/// Validates the file header (magic bytes and format version) before
209/// deserializing the index body.
210///
211/// # Security
212///
213/// Two guards prevent a corrupt or adversarial file from causing an OOM abort
214/// at startup:
215///
216/// 1. **File-size pre-check** — the file is stat'd before any deserialization;
217///    if it exceeds [`MAX_HNSW_BYTES`] the call returns `Err` immediately.
218/// 2. **Bounded deserialization** — bincode is configured with a byte limit
219///    equal to the remaining file size after the header.  This converts any
220///    attempt to read more bytes than the file contains into a recoverable
221///    `Err` rather than an allocator abort.
222///
223/// The magic / version header is validated **before** the unbounded body is
224/// touched, so corrupt non-HNSW files are rejected cheaply.
225pub fn load_from_file(path: &Path) -> Result<HnswIndex> {
226    let file = File::open(path)?;
227
228    // Guard 1: stat the file and reject before any deserialization if it
229    // exceeds the hard cap.  This is the first line of defence against a
230    // corrupt file claiming an impossibly large allocation.
231    let file_size = file.metadata()?.len();
232    if file_size > MAX_HNSW_BYTES {
233        return Err(AstraeaError::Deserialization(format!(
234            "HNSW file is too large ({file_size} bytes > {MAX_HNSW_BYTES} byte cap): \
235             refusing to load"
236        )));
237    }
238
239    let mut reader = BufReader::new(file);
240
241    // Read and validate the header first (magic, version, dimension, …).
242    // Header bytes are consumed by hand via `read_exact`, so bincode never
243    // sees them and we do not count them against the body limit below.
244    let header = read_header(&mut reader)?;
245
246    // Validate the metric byte is known.
247    let _metric = byte_to_metric(header.metric)?;
248
249    // Guard 2: bound the bincode body decode to the remaining file bytes.
250    //
251    // bincode 1.x free functions (serialize_into / deserialize_from) use
252    // FixintEncoding + AllowTrailing (see bincode/src/config/legacy.rs).
253    // DefaultOptions::new() uses VarintEncoding + RejectTrailing by default,
254    // so we must explicitly restore the free-function settings before adding
255    // the limit to avoid breaking existing files on disk.
256    //
257    // The limit converts an attacker-supplied huge collection-count field
258    // (which would otherwise call HashMap::with_capacity with an enormous
259    // hint) into a recoverable SizeLimit error.  serde already caps the
260    // initial with_capacity call via its internal `cautious()` helper
261    // (≤ 1 MiB / sizeof(element)), so the two layers together prevent both
262    // the upfront capacity OOM and the per-entry allocation OOM.
263    let body_limit = file_size.saturating_sub(HEADER_SIZE).max(1);
264    let index: HnswIndex = bincode::DefaultOptions::new()
265        .with_fixint_encoding()
266        .allow_trailing_bytes()
267        .with_limit(body_limit)
268        .deserialize_from(&mut reader)
269        .map_err(|e| {
270            AstraeaError::Deserialization(format!("bincode deserialization failed: {e}"))
271        })?;
272
273    // Cross-check header against deserialized data.
274    if index.dimension() != header.dimension as usize {
275        return Err(AstraeaError::Deserialization(format!(
276            "header/body dimension mismatch: header says {}, body has {}",
277            header.dimension,
278            index.dimension()
279        )));
280    }
281
282    Ok(index)
283}
284
285/// Load an `HnswIndex` from the file at `path`, and verify that its dimension
286/// matches `expected_dimension`.
287///
288/// This is useful when the caller has a configured dimension and wants to
289/// ensure the persisted index was built with the same dimension. If the
290/// dimensions do not match, `AstraeaError::DimensionMismatch` is returned
291/// and no partially-loaded state is exposed.
292///
293/// Existing callers should use [`load_from_file`] if they do not have a
294/// specific dimension expectation.
295pub fn load_from_file_with_dimension(path: &Path, expected_dimension: usize) -> Result<HnswIndex> {
296    let index = load_from_file(path)?;
297    let got = index.dimension();
298    if got != expected_dimension {
299        return Err(AstraeaError::DimensionMismatch {
300            expected: expected_dimension,
301            got,
302        });
303    }
304    Ok(index)
305}
306
307// --- Convenience methods on HnswIndex ---
308
309impl HnswIndex {
310    /// Persist this index to the given file path.
311    pub fn save(&self, path: &Path) -> Result<()> {
312        save_to_file(self, path)
313    }
314
315    /// Load an index from the given file path.
316    pub fn load(path: &Path) -> Result<Self> {
317        load_from_file(path)
318    }
319
320    /// Load an index from the given file path, verifying that the stored
321    /// dimension matches `expected_dimension`.
322    ///
323    /// Returns `AstraeaError::DimensionMismatch` when the dimensions differ.
324    pub fn load_expecting_dimension(path: &Path, expected_dimension: usize) -> Result<Self> {
325        load_from_file_with_dimension(path, expected_dimension)
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332    use astraea_core::types::NodeId;
333    use rand::Rng;
334    use tempfile::NamedTempFile;
335
336    /// Helper: create a small index, insert some vectors, return it.
337    fn build_test_index(dim: usize, n: usize) -> HnswIndex {
338        let mut idx = HnswIndex::new(dim, DistanceMetric::Euclidean, 16, 200);
339        let mut rng = rand::thread_rng();
340        for i in 0..n {
341            let v: Vec<f32> = (0..dim).map(|_| rng.r#gen::<f32>()).collect();
342            idx.insert(NodeId(i as u64), &v).unwrap();
343        }
344        idx
345    }
346
347    #[test]
348    fn test_round_trip_100_vectors() {
349        let dim = 32;
350        let n = 100;
351        let original = build_test_index(dim, n);
352
353        // Save to a temp file.
354        let tmp = NamedTempFile::new().unwrap();
355        original.save(tmp.path()).unwrap();
356
357        // Load it back.
358        let loaded = HnswIndex::load(tmp.path()).unwrap();
359
360        // Verify metadata matches.
361        assert_eq!(loaded.dimension(), original.dimension());
362        assert_eq!(loaded.metric(), original.metric());
363        assert_eq!(loaded.m(), original.m());
364        assert_eq!(loaded.m_max0(), original.m_max0());
365        assert_eq!(loaded.ef_construction(), original.ef_construction());
366        assert_eq!(loaded.len(), original.len());
367
368        // Verify search results match.
369        let mut rng = rand::thread_rng();
370        let query: Vec<f32> = (0..dim).map(|_| rng.r#gen::<f32>()).collect();
371        let k = 5;
372        let ef_search = 100;
373
374        let orig_results = original.search(&query, k, ef_search).unwrap();
375        let loaded_results = loaded.search(&query, k, ef_search).unwrap();
376
377        assert_eq!(orig_results.len(), loaded_results.len());
378        // The top result should be the same node with the same distance.
379        assert_eq!(orig_results[0].0, loaded_results[0].0);
380        assert!((orig_results[0].1 - loaded_results[0].1).abs() < 1e-6);
381    }
382
383    #[test]
384    fn test_round_trip_empty_index() {
385        let dim = 8;
386        let original = HnswIndex::new(dim, DistanceMetric::Cosine, 16, 200);
387        assert!(original.is_empty());
388
389        let tmp = NamedTempFile::new().unwrap();
390        original.save(tmp.path()).unwrap();
391
392        let loaded = HnswIndex::load(tmp.path()).unwrap();
393
394        assert_eq!(loaded.dimension(), dim);
395        assert_eq!(loaded.metric(), DistanceMetric::Cosine);
396        assert!(loaded.is_empty());
397        assert_eq!(loaded.len(), 0);
398
399        // Search on empty loaded index should return empty results.
400        let results = loaded.search(&vec![0.0; dim], 5, 50).unwrap();
401        assert!(results.is_empty());
402    }
403
404    #[test]
405    fn test_invalid_magic_bytes() {
406        let dim = 4;
407        let original = build_test_index(dim, 5);
408
409        let tmp = NamedTempFile::new().unwrap();
410        original.save(tmp.path()).unwrap();
411
412        // Corrupt the first 4 bytes (magic).
413        let mut data = std::fs::read(tmp.path()).unwrap();
414        data[0] = 0xFF;
415        data[1] = 0xFF;
416        data[2] = 0xFF;
417        data[3] = 0xFF;
418        std::fs::write(tmp.path(), &data).unwrap();
419
420        let result = HnswIndex::load(tmp.path());
421        assert!(result.is_err());
422        let err_msg = format!("{}", result.unwrap_err());
423        assert!(
424            err_msg.contains("invalid HNSW file magic"),
425            "expected magic error, got: {err_msg}"
426        );
427    }
428
429    #[test]
430    fn test_invalid_version() {
431        let dim = 4;
432        let original = build_test_index(dim, 5);
433
434        let tmp = NamedTempFile::new().unwrap();
435        original.save(tmp.path()).unwrap();
436
437        // Corrupt the version field (bytes 4..8) to version 99.
438        let mut data = std::fs::read(tmp.path()).unwrap();
439        let bad_version: u32 = 99;
440        data[4..8].copy_from_slice(&bad_version.to_le_bytes());
441        std::fs::write(tmp.path(), &data).unwrap();
442
443        let result = HnswIndex::load(tmp.path());
444        assert!(result.is_err());
445        let err_msg = format!("{}", result.unwrap_err());
446        assert!(
447            err_msg.contains("unsupported HNSW file version"),
448            "expected version error, got: {err_msg}"
449        );
450    }
451
452    #[test]
453    fn test_round_trip_cosine_metric() {
454        let dim = 16;
455        let n = 50;
456        let mut idx = HnswIndex::new(dim, DistanceMetric::Cosine, 8, 100);
457        let mut rng = rand::thread_rng();
458        for i in 0..n {
459            let v: Vec<f32> = (0..dim).map(|_| rng.r#gen::<f32>() + 0.01).collect();
460            idx.insert(NodeId(i as u64), &v).unwrap();
461        }
462
463        let tmp = NamedTempFile::new().unwrap();
464        idx.save(tmp.path()).unwrap();
465
466        let loaded = HnswIndex::load(tmp.path()).unwrap();
467        assert_eq!(loaded.metric(), DistanceMetric::Cosine);
468        assert_eq!(loaded.len(), n);
469        assert_eq!(loaded.m(), 8);
470        assert_eq!(loaded.ef_construction(), 100);
471    }
472
473    #[test]
474    fn test_round_trip_dot_product_metric() {
475        let dim = 8;
476        let n = 20;
477        let mut idx = HnswIndex::new(dim, DistanceMetric::DotProduct, 12, 150);
478        let mut rng = rand::thread_rng();
479        for i in 0..n {
480            let v: Vec<f32> = (0..dim).map(|_| rng.r#gen::<f32>()).collect();
481            idx.insert(NodeId(i as u64), &v).unwrap();
482        }
483
484        let tmp = NamedTempFile::new().unwrap();
485        idx.save(tmp.path()).unwrap();
486
487        let loaded = HnswIndex::load(tmp.path()).unwrap();
488        assert_eq!(loaded.metric(), DistanceMetric::DotProduct);
489        assert_eq!(loaded.len(), n);
490    }
491
492    #[test]
493    fn test_search_consistency_after_load() {
494        // Verify that multiple queries all produce identical results
495        // on the original and loaded indices.
496        let dim = 16;
497        let n = 80;
498        let original = build_test_index(dim, n);
499
500        let tmp = NamedTempFile::new().unwrap();
501        original.save(tmp.path()).unwrap();
502        let loaded = HnswIndex::load(tmp.path()).unwrap();
503
504        let mut rng = rand::thread_rng();
505        for _ in 0..10 {
506            let query: Vec<f32> = (0..dim).map(|_| rng.r#gen::<f32>()).collect();
507            let orig_results = original.search(&query, 3, 100).unwrap();
508            let loaded_results = loaded.search(&query, 3, 100).unwrap();
509
510            assert_eq!(orig_results.len(), loaded_results.len());
511            for (o, l) in orig_results.iter().zip(loaded_results.iter()) {
512                assert_eq!(o.0, l.0, "node IDs should match");
513                assert!((o.1 - l.1).abs() < 1e-6, "distances should match");
514            }
515        }
516    }
517
518    // --- Task 3 tests: checked persist cast + config-vs-file dimension enforcement ---
519
520    /// (a) Loading a persisted 128-dim index while expecting 768 returns DimensionMismatch.
521    #[test]
522    fn test_load_with_dimension_mismatch_returns_error() {
523        let dim = 128;
524        let original = build_test_index(dim, 10);
525        let tmp = NamedTempFile::new().unwrap();
526        original.save(tmp.path()).unwrap();
527
528        let result = HnswIndex::load_expecting_dimension(tmp.path(), 768);
529        assert!(
530            result.is_err(),
531            "expected DimensionMismatch error when loading 128-dim index expecting 768"
532        );
533        match result.unwrap_err() {
534            astraea_core::error::AstraeaError::DimensionMismatch { expected, got } => {
535                assert_eq!(expected, 768);
536                assert_eq!(got, 128);
537            }
538            other => panic!("expected DimensionMismatch, got: {other:?}"),
539        }
540    }
541
542    /// (b) Loading a persisted index while expecting the matching dimension succeeds.
543    #[test]
544    fn test_load_with_dimension_matching_succeeds() {
545        let dim = 128;
546        let original = build_test_index(dim, 10);
547        let tmp = NamedTempFile::new().unwrap();
548        original.save(tmp.path()).unwrap();
549
550        let loaded = HnswIndex::load_expecting_dimension(tmp.path(), dim);
551        assert!(
552            loaded.is_ok(),
553            "loading at the matching dimension should succeed"
554        );
555        assert_eq!(loaded.unwrap().dimension(), dim);
556    }
557
558    /// (c) A dimension greater than u32::MAX fails to persist with a clear error
559    ///     (tests the checked cast directly without allocating a giant index).
560    #[test]
561    fn test_save_dimension_exceeding_u32_max_returns_error() {
562        // u32::MAX + 1 = 4_294_967_296; construct the index but do not insert
563        // any vectors so no allocation is proportional to the dimension.
564        let huge_dim: usize = (u32::MAX as usize) + 1;
565        let idx = HnswIndex::new(huge_dim, DistanceMetric::Euclidean, 16, 200);
566
567        let tmp = NamedTempFile::new().unwrap();
568        let result = idx.save(tmp.path());
569        assert!(
570            result.is_err(),
571            "saving an index with dimension > u32::MAX must fail"
572        );
573        match result.unwrap_err() {
574            astraea_core::error::AstraeaError::Serialization(msg) => {
575                assert!(
576                    msg.contains("u32::MAX"),
577                    "error message should mention u32::MAX, got: {msg}"
578                );
579            }
580            other => panic!("expected Serialization error, got: {other:?}"),
581        }
582    }
583
584    // --- M1 security tests: bounded deserialization (no OOM abort on corrupt files) ---
585
586    /// A file with a valid HNSW header followed by random garbage in the body
587    /// must return `Err`, not panic or abort the process.
588    #[test]
589    fn test_corrupt_body_garbage_returns_err_not_abort() {
590        // Build header bytes manually so we can control every field.
591        let dim: u32 = 4;
592        let mut data: Vec<u8> = Vec::new();
593
594        // Valid header (37 bytes).
595        data.extend_from_slice(&MAGIC.to_le_bytes());
596        data.extend_from_slice(&FORMAT_VERSION.to_le_bytes());
597        data.extend_from_slice(&dim.to_le_bytes());
598        data.push(0u8); // metric = Cosine
599        data.extend_from_slice(&16u32.to_le_bytes()); // m
600        data.extend_from_slice(&32u32.to_le_bytes()); // m_max0
601        data.extend_from_slice(&200u32.to_le_bytes()); // ef_construction
602        data.extend_from_slice(&0u64.to_le_bytes()); // num_vectors
603        data.extend_from_slice(&0u32.to_le_bytes()); // num_layers
604
605        // 200 bytes of 0xFF as the "body" — cannot decode as a valid HnswIndex.
606        data.extend(std::iter::repeat_n(0xFFu8, 200));
607
608        let tmp = NamedTempFile::new().unwrap();
609        std::fs::write(tmp.path(), &data).unwrap();
610
611        let result = HnswIndex::load(tmp.path());
612        assert!(
613            result.is_err(),
614            "loading a file with a garbage body must return Err, not panic or abort"
615        );
616    }
617
618    /// A file with a valid HNSW header and a body that declares `u64::MAX`
619    /// vectors (an adversarially huge collection count) must return `Err`
620    /// without OOM-aborting the process.
621    ///
622    /// Without the `with_limit` guard, bincode would propagate this count to
623    /// serde which would call `HashMap::with_capacity(huge)` before reading
624    /// any entries.  With the guard, bincode exhausts the body-byte limit as
625    /// soon as it tries to read the first HashMap entry and returns a
626    /// recoverable `SizeLimit` error.
627    ///
628    /// Byte layout rationale (FixintEncoding, little-endian):
629    ///   struct HnswIndex preamble = dimension(8) + metric(4) + m(8) +
630    ///                               m_max0(8) + ef_construction(8) + ml(8)
631    ///                             = 44 bytes consumed before `vectors` count
632    ///   vectors HashMap count     =  8 bytes (we set this to u64::MAX)
633    /// Total body written          = 52 bytes  →  body_limit = 52
634    /// After reading the count, limit = 0.  Next read for first entry key
635    /// (NodeId = 8 bytes) underflows the limit → SizeLimit Err returned.
636    #[test]
637    fn test_corrupt_body_huge_vector_count_returns_err_not_abort() {
638        let dim: u32 = 4;
639        let m: u32 = 16;
640        let m_max0: u32 = 32;
641        let ef_construction: u32 = 200;
642        let ml: f64 = 1.0_f64 / (m as f64).ln();
643
644        let mut data: Vec<u8> = Vec::new();
645
646        // Valid header (37 bytes).
647        data.extend_from_slice(&MAGIC.to_le_bytes());
648        data.extend_from_slice(&FORMAT_VERSION.to_le_bytes());
649        data.extend_from_slice(&dim.to_le_bytes());
650        data.push(0u8); // metric = Cosine
651        data.extend_from_slice(&m.to_le_bytes());
652        data.extend_from_slice(&m_max0.to_le_bytes());
653        data.extend_from_slice(&ef_construction.to_le_bytes());
654        data.extend_from_slice(&0u64.to_le_bytes()); // num_vectors (header field)
655        data.extend_from_slice(&0u32.to_le_bytes()); // num_layers
656
657        // Body: HnswIndex struct preamble in FixintEncoding (matches serialize_into).
658        data.extend_from_slice(&(dim as u64).to_le_bytes()); // dimension: usize → u64
659        data.extend_from_slice(&0u32.to_le_bytes()); // metric discriminant (Cosine = 0)
660        data.extend_from_slice(&(m as u64).to_le_bytes()); // m: usize
661        data.extend_from_slice(&(m_max0 as u64).to_le_bytes()); // m_max0: usize
662        data.extend_from_slice(&(ef_construction as u64).to_le_bytes()); // ef_construction: usize
663        data.extend_from_slice(&ml.to_le_bytes()); // ml: f64
664        // Adversarial vectors HashMap count — claims u64::MAX entries.
665        data.extend_from_slice(&u64::MAX.to_le_bytes());
666        // No entry bytes follow; body is truncated right after the count.
667
668        let tmp = NamedTempFile::new().unwrap();
669        std::fs::write(tmp.path(), &data).unwrap();
670
671        // Must return Err, not OOM-abort or panic.
672        let result = HnswIndex::load(tmp.path());
673        assert!(
674            result.is_err(),
675            "loading a file claiming u64::MAX vectors must return Err, not abort"
676        );
677        // The error must be a recoverable Deserialization variant.
678        match result.unwrap_err() {
679            AstraeaError::Deserialization(_) => {} // expected
680            other => panic!("expected Deserialization error, got: {other:?}"),
681        }
682    }
683
684    /// (e) Persistence round-trip at the motivating 768-dim size preserves the header dimension.
685    ///
686    /// This is the key non-128 regression guard: if a future change reintroduces
687    /// a hard-coded 128, this test will fail on load because the deserialized
688    /// dimension will be 128 while the header will say 768 (or vice versa).
689    #[test]
690    fn test_round_trip_preserves_non_128_dimension_768() {
691        const DIM: usize = 768;
692        let mut idx = HnswIndex::new(DIM, DistanceMetric::Cosine, 16, 200);
693        let mut rng = rand::thread_rng();
694
695        // Insert a handful of 768-dim vectors.
696        for i in 0..5u64 {
697            let v: Vec<f32> = (0..DIM).map(|_| rng.r#gen::<f32>()).collect();
698            idx.insert(NodeId(i), &v).unwrap();
699        }
700        assert_eq!(idx.dimension(), DIM);
701
702        let tmp = NamedTempFile::new().unwrap();
703        idx.save(tmp.path()).unwrap();
704
705        let loaded = HnswIndex::load(tmp.path()).unwrap();
706
707        assert_eq!(
708            loaded.dimension(),
709            DIM,
710            "loaded index dimension must equal the saved 768, not be truncated or defaulted"
711        );
712        assert_eq!(loaded.metric(), DistanceMetric::Cosine);
713        assert_eq!(loaded.len(), 5);
714
715        // Verify load_expecting_dimension also succeeds at the correct dim.
716        let loaded2 = HnswIndex::load_expecting_dimension(tmp.path(), DIM).unwrap();
717        assert_eq!(loaded2.dimension(), DIM);
718
719        // And fails with DimensionMismatch when the expected dim is wrong.
720        let wrong = HnswIndex::load_expecting_dimension(tmp.path(), 128);
721        match wrong {
722            Err(astraea_core::error::AstraeaError::DimensionMismatch { expected, got }) => {
723                assert_eq!(expected, 128);
724                assert_eq!(got, DIM);
725            }
726            other => panic!("expected DimensionMismatch(128, 768), got: {other:?}"),
727        }
728    }
729}