Skip to main content

vecq_core/
format.rs

1//! Single-file persistence format.
2//!
3//! Layout (little-endian):
4//! ```text
5//! [magic "VECQ" u32][version u16][reserved u16]
6//! [dim u32][seed u64][count u32]
7//! [scales: count entries]
8//! [codes: count * (padded/2) bytes]
9//! ```
10//!
11//! Version 1: scales stored as f32 (4 bytes each).
12//! Version 1.1 (stored as 257): scales stored as f16 (2 bytes each).
13//! Version 1.2 (stored as 258): the reserved u16 field carries the index's
14//! `working_dim` (Matryoshka truncation; 0 means working_dim == dim). Codes
15//! and scales are laid out exactly like 1.1 — only the semantic of the
16//! reserved field changes.
17//! Version 1.3 (stored as 259): after the codes block, a keyed-slot table
18//! restores the keyed API across reloads:
19//! ```text
20//! [keyed_entries u32][entries: slot u32 + key u64 each, slots in order]
21//! ```
22//!
23//! Version 1.4 (stored as 260): a residual-mode index (second-pass codes,
24//! issue #23). After the codes block, two extra blocks appear — f16 residual
25//! scales (`count` entries) and residual codes (`count * padded/2` bytes) —
26//! before the keyed-slot table.
27//!
28//! Readers accept v1 through v1.5; writers emit 1.3 (plain 4-bit), 1.4
29//! (residual), or 1.5 (plain 5/6-bit, issue #39). The seed is stored in the
30//! header so the random sign diagonal
31//! can be regenerated identically on any platform: identical file ->
32//! identical query results, bit for bit.
33
34use crate::store::VecqIndex;
35
36pub(crate) const MAGIC: u32 = u32::from_le_bytes(*b"VECQ");
37const V1: u16 = 1;
38const V1_1: u16 = 257;
39pub const V1_2: u16 = 258;
40pub(crate) const V1_3: u16 = 259;
41pub(crate) const V1_4: u16 = 260;
42pub(crate) const V1_5: u16 = 261;
43
44#[derive(Debug)]
45pub enum Error {
46    NotAStableFile,
47    UnsupportedVersion(u16),
48    Truncated,
49    DimMismatch { expected: usize, got: usize },
50    InvalidWorkingDim { dim: usize, working_dim: usize },
51    InvalidKeyTable,
52    InvalidWidth { width: u8 },
53}
54
55impl std::fmt::Display for Error {
56    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57        match self {
58            Error::NotAStableFile => write!(f, "not a vecq file"),
59            Error::UnsupportedVersion(v) => write!(f, "unsupported version {v}"),
60            Error::Truncated => write!(f, "file truncated"),
61            Error::DimMismatch { expected, got } => {
62                write!(f, "dim mismatch: expected {expected}, got {got}")
63            }
64            Error::InvalidWorkingDim { dim, working_dim } => {
65                write!(f, "invalid working_dim {working_dim} for dim {dim}")
66            }
67            Error::InvalidKeyTable => write!(f, "invalid keyed-slot table"),
68            Error::InvalidWidth { width } => {
69                write!(f, "invalid code width {width} (supported: 4, 5, 6)")
70            }
71        }
72    }
73}
74
75impl std::error::Error for Error {}
76
77/// IEEE 754 half-precision encode (round to nearest even), no dependencies.
78fn f32_to_f16_bits(x: f32) -> u16 {
79    let bits = x.to_bits();
80    let sign = ((bits >> 16) & 0x8000) as u16;
81    let exp = ((bits >> 23) & 0xFF) as i32;
82    let mant = bits & 0x7F_FFFF;
83    if exp == 0xFF {
84        // Inf / NaN
85        return sign | 0x7C00 | if mant != 0 { 0x0200 } else { 0 };
86    }
87    let unbiased = exp - 127;
88    if unbiased > 15 {
89        return sign | 0x7C00; // overflow -> Inf
90    }
91    if unbiased >= -14 {
92        // Normal half
93        let half_exp = (unbiased + 15) as u32;
94        let half_mant = mant >> 13;
95        let mut out = sign | ((half_exp << 10) as u16) | half_mant as u16;
96        // Round to nearest even on the dropped 13 bits.
97        let round = mant & 0x1FFF;
98        if round > 0x1000 || (round == 0x1000 && (half_mant & 1 == 1)) {
99            out = out.wrapping_add(1);
100        }
101        out
102    } else {
103        // Subnormal half
104        let m = mant | 0x80_0000; // implicit leading 1
105        let shift = (-unbiased - 14 + 13) as u32;
106        if shift >= 32 {
107            return sign;
108        }
109        let half_mant = m >> shift;
110        let mut out = sign | half_mant as u16;
111        let round_bits = m & ((1u32 << shift) - 1);
112        let halfway = 1u32 << (shift - 1);
113        if round_bits > halfway || (round_bits == halfway && (half_mant & 1 == 1)) {
114            out = out.wrapping_add(1);
115        }
116        out
117    }
118}
119
120/// IEEE 754 half-precision decode.
121pub(crate) fn f16_bits_to_f32(h: u16) -> f32 {
122    let sign = ((h & 0x8000) as u32) << 16;
123    let exp = ((h >> 10) & 0x1F) as u32;
124    let mant = (h & 0x03FF) as u32;
125    let bits = if exp == 0 {
126        if mant == 0 {
127            sign
128        } else {
129            // Subnormal: normalize
130            let mut e = -1i32;
131            let mut m = mant;
132            while m & 0x0400 == 0 {
133                m <<= 1;
134                e -= 1;
135            }
136            m &= 0x03FF;
137            sign | (((113 + e) as u32) << 23) | (m << 13)
138        }
139    } else if exp == 0x1F {
140        sign | 0x7F80_0000 | (mant << 13)
141    } else {
142        sign | ((exp + 112) << 23) | (mant << 13)
143    };
144    f32::from_bits(bits)
145}
146
147impl VecqIndex {
148    /// Serialize the index to bytes. Plain indexes emit format version 1.3;
149    /// residual indexes emit 1.4 (extra residual scale + code blocks).
150    ///
151    /// Tombstoned slots are skipped: the output always holds the live vectors
152    /// in slot order, so a round-trip through bytes has the same effect as
153    /// [`VecqIndex::compact`] on disk without disturbing in-memory slot
154    /// indices. Keys of live keyed slots are stored in the keyed-slot table
155    /// and are fully restored by [`VecqIndex::from_bytes`].
156    pub fn to_bytes(&self) -> Vec<u8> {
157        let bits = self.bits();
158        let bpv = self.bytes_per_vector();
159        let reserved: u16 = if self.working_dim() == self.dim() {
160            0
161        } else {
162            self.working_dim() as u16
163        };
164        // 1.3 keeps 4-bit plain files byte-identical with pre-#39 output;
165        // 1.5 adds one width byte for non-4-bit plain indexes.
166        let wide = !self.is_residual() && bits != 4;
167        let version = if self.is_residual() {
168            V1_4
169        } else if wide {
170            V1_5
171        } else {
172            V1_3
173        };
174        let extra = if self.is_residual() {
175            self.live_slots() * (2 + bpv)
176        } else {
177            0
178        };
179        let width_byte = usize::from(wide);
180        let mut out = Vec::with_capacity(
181            24 + width_byte + self.live_slots() * bpv + self.live_slots() * 2 + extra,
182        );
183        out.extend_from_slice(&MAGIC.to_le_bytes());
184        out.extend_from_slice(&version.to_le_bytes());
185        out.extend_from_slice(&reserved.to_le_bytes());
186        out.extend_from_slice(&(self.dim() as u32).to_le_bytes());
187        out.extend_from_slice(&self.seed().to_le_bytes());
188        out.extend_from_slice(&(self.len() as u32).to_le_bytes());
189        if wide {
190            out.push(bits);
191        }
192        for slot in 0..self.slots() {
193            if !self.slot_alive(slot) {
194                continue;
195            }
196            out.extend_from_slice(&f32_to_f16_bits(self.slot_scale(slot)).to_le_bytes());
197        }
198        for slot in 0..self.slots() {
199            if !self.slot_alive(slot) {
200                continue;
201            }
202            out.extend_from_slice(self.slot_codes(slot, bpv));
203        }
204        if self.is_residual() {
205            for slot in 0..self.slots() {
206                if !self.slot_alive(slot) {
207                    continue;
208                }
209                out.extend_from_slice(&f32_to_f16_bits(self.slot_scale2(slot)).to_le_bytes());
210            }
211            for slot in 0..self.slots() {
212                if !self.slot_alive(slot) {
213                    continue;
214                }
215                out.extend_from_slice(self.slot_codes2(slot, bpv));
216            }
217        }
218        // Keyed-slot table (v1.3): restores the keyed API across reloads.
219        // Slot ids are DENSE positions among the serialized (alive) slots —
220        // the reader's slot space — not the in-memory slot indices, which
221        // may exceed the serialized count when tombstones are dropped.
222        let keyed: Vec<(u32, u64)> = (0..self.slots())
223            .filter(|&s| self.slot_alive(s))
224            .enumerate()
225            .filter_map(|(dense, s)| self.key_of(s).map(|k| (dense as u32, k)))
226            .collect();
227        out.extend_from_slice(&(keyed.len() as u32).to_le_bytes());
228        for (slot, key) in keyed {
229            out.extend_from_slice(&slot.to_le_bytes());
230            out.extend_from_slice(&key.to_le_bytes());
231        }
232        out
233    }
234
235    /// Parse an index from bytes produced by [`to_bytes`] (a v1.3 file) or a
236    /// legacy v1 / v1.1 / v1.2 file (which carry no key table).
237    pub fn from_bytes(bytes: &[u8]) -> Result<Self, Error> {
238        let rd_u32 = |b: &[u8]| u32::from_le_bytes([b[0], b[1], b[2], b[3]]);
239        let rd_u16 = |b: &[u8]| u16::from_le_bytes([b[0], b[1]]);
240        if bytes.len() < 24 {
241            return Err(Error::Truncated);
242        }
243        if rd_u32(&bytes[0..4]) != MAGIC {
244            return Err(Error::NotAStableFile);
245        }
246        let version = rd_u16(&bytes[4..6]);
247        if version != V1
248            && version != V1_1
249            && version != V1_2
250            && version != V1_3
251            && version != V1_4
252            && version != V1_5
253        {
254            return Err(Error::UnsupportedVersion(version));
255        }
256        let dim = rd_u32(&bytes[8..12]) as usize;
257        let seed = u64::from_le_bytes(bytes[12..20].try_into().unwrap());
258        let count = rd_u32(&bytes[20..24]) as usize;
259
260        // v1.2+ carry working_dim in the reserved field (0 = full dim).
261        let working_dim = match version {
262            V1_2 | V1_3 | V1_4 | V1_5 => match rd_u16(&bytes[6..8]) as usize {
263                0 => dim,
264                w if w <= dim => w,
265                w => {
266                    return Err(Error::InvalidWorkingDim {
267                        dim,
268                        working_dim: w,
269                    })
270                }
271            },
272            _ => dim,
273        };
274
275        let padded = crate::rhdh::padded_dim(working_dim);
276        // v1.5 carries an explicit Lloyd-Max width byte right after the
277        // header; all older versions are implicitly 4-bit.
278        let (index_bits, mut off) = if version == V1_5 {
279            if bytes.len() < 25 {
280                return Err(Error::Truncated);
281            }
282            let w = bytes[24];
283            if !matches!(w, 4..=6) {
284                return Err(Error::InvalidWidth { width: w });
285            }
286            (w, 25usize)
287        } else {
288            (4u8, 24usize)
289        };
290        let codes_bytes = (padded * index_bits as usize).div_ceil(8);
291        let scale_bytes = if version == V1 { 4 } else { 2 };
292        let expected = off + count * (scale_bytes + codes_bytes);
293        if bytes.len() < expected {
294            return Err(Error::Truncated);
295        }
296
297        let mut scales = Vec::with_capacity(count);
298        for _ in 0..count {
299            let s = if version == V1 {
300                f32::from_le_bytes(bytes[off..off + 4].try_into().unwrap())
301            } else {
302                f16_bits_to_f32(rd_u16(&bytes[off..off + 2]))
303            };
304            scales.push(s);
305            off += scale_bytes;
306        }
307        let code_end = off + count * codes_bytes;
308        let mut index = VecqIndex::with_working_dim(dim, working_dim, seed);
309        index.codes = bytes[off..code_end].to_vec();
310        index.scales = scales;
311        index.n = count;
312        index.init_dense(count);
313        index.bits = index_bits;
314        if version == V1_4 {
315            // Residual blocks: f16 scales2, then codes2.
316            index.residual = true;
317            if bytes.len() < code_end + count * (2 + codes_bytes) {
318                return Err(Error::Truncated);
319            }
320            let mut off2 = code_end;
321            for _ in 0..count {
322                index
323                    .scales2
324                    .push(f16_bits_to_f32(rd_u16(&bytes[off2..off2 + 2])));
325                off2 += 2;
326            }
327            index.codes2 = bytes[off2..off2 + count * codes_bytes].to_vec();
328        }
329        if version == V1_3 || version == V1_4 || version == V1_5 {
330            // Keyed-slot table: [entries u32][slot u32 + key u64 each].
331            if bytes.len() < code_end + 4 {
332                return Err(Error::Truncated);
333            }
334            let key_base = if version == V1_4 {
335                code_end + count * (2 + codes_bytes)
336            } else {
337                code_end
338            };
339            let entries = rd_u32(&bytes[key_base..key_base + 4]) as usize;
340            let table_end = key_base + 4 + entries * 12;
341            if bytes.len() < table_end {
342                return Err(Error::Truncated);
343            }
344            let mut key_table: Vec<Option<u64>> = vec![None; count];
345            let mut e = key_base + 4;
346            for _ in 0..entries {
347                let slot = rd_u32(&bytes[e..e + 4]) as usize;
348                let key = u64::from_le_bytes(bytes[e + 4..e + 12].try_into().unwrap());
349                e += 12;
350                if slot >= count || key_table[slot].is_some() {
351                    return Err(Error::InvalidKeyTable);
352                }
353                key_table[slot] = Some(key);
354            }
355            index.restore_keys(key_table);
356        }
357        Ok(index)
358    }
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    fn unit(dim: usize, salt: u64) -> Vec<f32> {
366        let mut x = salt | 1;
367        let mut v = Vec::with_capacity(dim);
368        for _ in 0..dim {
369            x ^= x << 13;
370            x ^= x >> 7;
371            x ^= x << 17;
372            v.push((x as f32 / u32::MAX as f32 - 0.5) * 2.0);
373        }
374        let norm: f32 = v.iter().map(|a| a * a).sum::<f32>().sqrt();
375        v.into_iter().map(|a| a / norm).collect()
376    }
377
378    #[test]
379    fn round_trip_is_identical() {
380        let mut idx = VecqIndex::new(384, 99);
381        for i in 0..50 {
382            idx.add(&unit(384, i * 7 + 1));
383        }
384        let bytes = idx.to_bytes();
385        let back = VecqIndex::from_bytes(&bytes).expect("parse");
386        assert_eq!(back.len(), 50);
387        assert_eq!(back.dim(), 384);
388        assert_eq!(back.seed(), 99);
389        // Same file -> identical search results (the cross-platform guarantee).
390        let q = unit(384, 4242);
391        let r1 = back.search(&q, 10);
392        let bytes2 = back.to_bytes();
393        assert_eq!(bytes, bytes2, "re-serialize must be byte-identical");
394        let back2 = VecqIndex::from_bytes(&bytes2).unwrap();
395        assert_eq!(r1, back2.search(&q, 10));
396        // f16 scales perturb scores by <1e-3: top-10 overlap must stay >= 9/10
397        // and the top-1 must match.
398        let r0 = idx.search(&q, 10);
399        assert_eq!(r0[0].0, r1[0].0);
400        let overlap = r0
401            .iter()
402            .filter(|(i, _)| r1.iter().any(|(j, _)| i == j))
403            .count();
404        assert!(overlap >= 9, "top-10 overlap {overlap}");
405    }
406
407    #[test]
408    fn v11_file_smaller_and_v1_readable() {
409        let mut idx = VecqIndex::new(384, 7);
410        idx.set_bits(4); // legacy layout exercise: 4-bit payload, known offsets
411        for i in 0..30 {
412            idx.add(&unit(384, i + 3));
413        }
414        let v11 = idx.to_bytes();
415        // v1 file (f32 scales) — synthesize manually.
416        let mut v1 = Vec::new();
417        v1.extend_from_slice(&MAGIC.to_le_bytes());
418        v1.extend_from_slice(&V1.to_le_bytes());
419        v1.extend_from_slice(&0u16.to_le_bytes());
420        v1.extend_from_slice(&(384u32).to_le_bytes());
421        v1.extend_from_slice(&7u64.to_le_bytes());
422        v1.extend_from_slice(&(30u32).to_le_bytes());
423        for s in &idx.scales {
424            v1.extend_from_slice(&s.to_le_bytes());
425        }
426        // codes live at a known offset in v11: 24 + 30*2
427        let codes = &v11[24 + 30 * 2..];
428        v1.extend_from_slice(codes);
429        let from_v1 = VecqIndex::from_bytes(&v1).expect("v1 readable");
430        assert_eq!(from_v1.len(), 30);
431        // Search results identical (f16 rounding of scales is below tie threshold here)
432        let q = unit(384, 55);
433        assert_eq!(idx.search(&q, 5), from_v1.search(&q, 5));
434        // v1.1 must be 2 bytes/vector smaller
435        assert_eq!(v1.len() - v11.len(), 30 * 2);
436    }
437
438    #[test]
439    fn deterministic_across_instances() {
440        let mut a = VecqIndex::new(64, 5);
441        let mut b = VecqIndex::new(64, 5);
442        for i in 0..10 {
443            let v = unit(64, i + 100);
444            a.add(&v);
445            b.add(&v);
446        }
447        assert_eq!(a.to_bytes(), b.to_bytes());
448    }
449
450    #[test]
451    fn rejects_garbage() {
452        assert!(matches!(
453            VecqIndex::from_bytes(b"nonsense-not-a-file-at-all"),
454            Err(Error::NotAStableFile)
455        ));
456        assert!(matches!(VecqIndex::from_bytes(&[]), Err(Error::Truncated)));
457    }
458
459    #[test]
460    fn f16_round_trip_accuracy() {
461        // Scales are ~1/sqrt(d)-ish small positives; verify decode(encode(x)) ~ x.
462        for &x in &[0.001f32, 0.03, 0.5, 1.0, 3.7, 100.0] {
463            let back = f16_bits_to_f32(f32_to_f16_bits(x));
464            assert!((back - x).abs() / x < 0.001, "{x} -> {back}");
465        }
466    }
467}