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//!
14//! Readers accept both; writers emit 1.1. The seed is stored in the header so
15//! the random sign diagonal can be regenerated identically on any platform:
16//! identical file -> identical query results, bit for bit.
17
18use crate::store::VecqIndex;
19
20const MAGIC: u32 = u32::from_le_bytes(*b"VECQ");
21const V1: u16 = 1;
22const V1_1: u16 = 257;
23
24#[derive(Debug)]
25pub enum Error {
26    NotAStableFile,
27    UnsupportedVersion(u16),
28    Truncated,
29    DimMismatch { expected: usize, got: usize },
30}
31
32impl std::fmt::Display for Error {
33    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34        match self {
35            Error::NotAStableFile => write!(f, "not a vecq file"),
36            Error::UnsupportedVersion(v) => write!(f, "unsupported version {v}"),
37            Error::Truncated => write!(f, "file truncated"),
38            Error::DimMismatch { expected, got } => {
39                write!(f, "dim mismatch: expected {expected}, got {got}")
40            }
41        }
42    }
43}
44
45impl std::error::Error for Error {}
46
47/// IEEE 754 half-precision encode (round to nearest even), no dependencies.
48fn f32_to_f16_bits(x: f32) -> u16 {
49    let bits = x.to_bits();
50    let sign = ((bits >> 16) & 0x8000) as u16;
51    let exp = ((bits >> 23) & 0xFF) as i32;
52    let mant = bits & 0x7F_FFFF;
53    if exp == 0xFF {
54        // Inf / NaN
55        return sign | 0x7C00 | if mant != 0 { 0x0200 } else { 0 };
56    }
57    let unbiased = exp - 127;
58    if unbiased > 15 {
59        return sign | 0x7C00; // overflow -> Inf
60    }
61    if unbiased >= -14 {
62        // Normal half
63        let half_exp = (unbiased + 15) as u32;
64        let half_mant = mant >> 13;
65        let mut out = sign | ((half_exp << 10) as u16) | half_mant as u16;
66        // Round to nearest even on the dropped 13 bits.
67        let round = mant & 0x1FFF;
68        if round > 0x1000 || (round == 0x1000 && (half_mant & 1 == 1)) {
69            out = out.wrapping_add(1);
70        }
71        out
72    } else {
73        // Subnormal half
74        let m = mant | 0x80_0000; // implicit leading 1
75        let shift = (-unbiased - 14 + 13) as u32;
76        if shift >= 32 {
77            return sign;
78        }
79        let half_mant = m >> shift;
80        let mut out = sign | half_mant as u16;
81        let round_bits = m & ((1u32 << shift) - 1);
82        let halfway = 1u32 << (shift - 1);
83        if round_bits > halfway || (round_bits == halfway && (half_mant & 1 == 1)) {
84            out = out.wrapping_add(1);
85        }
86        out
87    }
88}
89
90/// IEEE 754 half-precision decode.
91fn f16_bits_to_f32(h: u16) -> f32 {
92    let sign = ((h & 0x8000) as u32) << 16;
93    let exp = ((h >> 10) & 0x1F) as u32;
94    let mant = (h & 0x03FF) as u32;
95    let bits = if exp == 0 {
96        if mant == 0 {
97            sign
98        } else {
99            // Subnormal: normalize
100            let mut e = -1i32;
101            let mut m = mant;
102            while m & 0x0400 == 0 {
103                m <<= 1;
104                e -= 1;
105            }
106            m &= 0x03FF;
107            sign | (((113 + e) as u32) << 23) | (m << 13)
108        }
109    } else if exp == 0x1F {
110        sign | 0x7F80_0000 | (mant << 13)
111    } else {
112        sign | ((exp + 112) << 23) | (mant << 13)
113    };
114    f32::from_bits(bits)
115}
116
117impl VecqIndex {
118    /// Serialize the index to bytes (format version 1.1, f16 scales).
119    ///
120    /// Tombstoned slots are skipped: the output always holds the live vectors
121    /// in slot order, so a round-trip through bytes has the same effect as
122    /// [`VecqIndex::compact`] on disk without disturbing in-memory slot
123    /// indices. Keys are not part of the file format; persist a key→slot
124    /// table alongside (e.g. in SQLite) if you need keyed access across a
125    /// reload.
126    pub fn to_bytes(&self) -> Vec<u8> {
127        let bpv = self.padded_dim() / 2;
128        let mut out = Vec::with_capacity(24 + self.live_slots() * bpv + self.live_slots() * 2);
129        out.extend_from_slice(&MAGIC.to_le_bytes());
130        out.extend_from_slice(&V1_1.to_le_bytes());
131        out.extend_from_slice(&0u16.to_le_bytes());
132        out.extend_from_slice(&(self.dim() as u32).to_le_bytes());
133        out.extend_from_slice(&self.seed().to_le_bytes());
134        out.extend_from_slice(&(self.len() as u32).to_le_bytes());
135        for slot in 0..self.slots() {
136            if !self.slot_alive(slot) {
137                continue;
138            }
139            out.extend_from_slice(&f32_to_f16_bits(self.slot_scale(slot)).to_le_bytes());
140        }
141        for slot in 0..self.slots() {
142            if !self.slot_alive(slot) {
143                continue;
144            }
145            out.extend_from_slice(self.slot_codes(slot, bpv));
146        }
147        out
148    }
149
150    /// Parse an index from bytes produced by [`to_bytes`] (or a v1 file).
151    pub fn from_bytes(bytes: &[u8]) -> Result<Self, Error> {
152        let rd_u32 = |b: &[u8]| u32::from_le_bytes([b[0], b[1], b[2], b[3]]);
153        let rd_u16 = |b: &[u8]| u16::from_le_bytes([b[0], b[1]]);
154        if bytes.len() < 24 {
155            return Err(Error::Truncated);
156        }
157        if rd_u32(&bytes[0..4]) != MAGIC {
158            return Err(Error::NotAStableFile);
159        }
160        let version = rd_u16(&bytes[4..6]);
161        if version != V1 && version != V1_1 {
162            return Err(Error::UnsupportedVersion(version));
163        }
164        let dim = rd_u32(&bytes[8..12]) as usize;
165        let seed = u64::from_le_bytes(bytes[12..20].try_into().unwrap());
166        let count = rd_u32(&bytes[20..24]) as usize;
167
168        let padded = crate::rhdh::padded_dim(dim);
169        let codes_bytes = padded / 2;
170        let scale_bytes = if version == V1 { 4 } else { 2 };
171        let expected = 24 + count * (scale_bytes + codes_bytes);
172        if bytes.len() < expected {
173            return Err(Error::Truncated);
174        }
175
176        let mut scales = Vec::with_capacity(count);
177        let mut off = 24;
178        for _ in 0..count {
179            let s = if version == V1 {
180                f32::from_le_bytes(bytes[off..off + 4].try_into().unwrap())
181            } else {
182                f16_bits_to_f32(rd_u16(&bytes[off..off + 2]))
183            };
184            scales.push(s);
185            off += scale_bytes;
186        }
187        let mut index = VecqIndex::new(dim, seed);
188        index.codes = bytes[off..off + count * codes_bytes].to_vec();
189        index.scales = scales;
190        index.n = count;
191        index.init_dense(count);
192        Ok(index)
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    fn unit(dim: usize, salt: u64) -> Vec<f32> {
201        let mut x = salt | 1;
202        let mut v = Vec::with_capacity(dim);
203        for _ in 0..dim {
204            x ^= x << 13;
205            x ^= x >> 7;
206            x ^= x << 17;
207            v.push((x as f32 / u32::MAX as f32 - 0.5) * 2.0);
208        }
209        let norm: f32 = v.iter().map(|a| a * a).sum::<f32>().sqrt();
210        v.into_iter().map(|a| a / norm).collect()
211    }
212
213    #[test]
214    fn round_trip_is_identical() {
215        let mut idx = VecqIndex::new(384, 99);
216        for i in 0..50 {
217            idx.add(&unit(384, i * 7 + 1));
218        }
219        let bytes = idx.to_bytes();
220        let back = VecqIndex::from_bytes(&bytes).expect("parse");
221        assert_eq!(back.len(), 50);
222        assert_eq!(back.dim(), 384);
223        assert_eq!(back.seed(), 99);
224        // Same file -> identical search results (the cross-platform guarantee).
225        let q = unit(384, 4242);
226        let r1 = back.search(&q, 10);
227        let bytes2 = back.to_bytes();
228        assert_eq!(bytes, bytes2, "re-serialize must be byte-identical");
229        let back2 = VecqIndex::from_bytes(&bytes2).unwrap();
230        assert_eq!(r1, back2.search(&q, 10));
231        // f16 scales perturb scores by <1e-3: top-10 overlap must stay >= 9/10
232        // and the top-1 must match.
233        let r0 = idx.search(&q, 10);
234        assert_eq!(r0[0].0, r1[0].0);
235        let overlap = r0
236            .iter()
237            .filter(|(i, _)| r1.iter().any(|(j, _)| i == j))
238            .count();
239        assert!(overlap >= 9, "top-10 overlap {overlap}");
240    }
241
242    #[test]
243    fn v11_file_smaller_and_v1_readable() {
244        let mut idx = VecqIndex::new(384, 7);
245        for i in 0..30 {
246            idx.add(&unit(384, i + 3));
247        }
248        let v11 = idx.to_bytes();
249        // v1 file (f32 scales) — synthesize manually.
250        let mut v1 = Vec::new();
251        v1.extend_from_slice(&MAGIC.to_le_bytes());
252        v1.extend_from_slice(&V1.to_le_bytes());
253        v1.extend_from_slice(&0u16.to_le_bytes());
254        v1.extend_from_slice(&(384u32).to_le_bytes());
255        v1.extend_from_slice(&7u64.to_le_bytes());
256        v1.extend_from_slice(&(30u32).to_le_bytes());
257        for s in &idx.scales {
258            v1.extend_from_slice(&s.to_le_bytes());
259        }
260        // codes live at a known offset in v11: 24 + 30*2
261        let codes = &v11[24 + 30 * 2..];
262        v1.extend_from_slice(codes);
263        let from_v1 = VecqIndex::from_bytes(&v1).expect("v1 readable");
264        assert_eq!(from_v1.len(), 30);
265        // Search results identical (f16 rounding of scales is below tie threshold here)
266        let q = unit(384, 55);
267        assert_eq!(idx.search(&q, 5), from_v1.search(&q, 5));
268        // v1.1 must be 2 bytes/vector smaller
269        assert_eq!(v1.len() - v11.len(), 30 * 2);
270    }
271
272    #[test]
273    fn deterministic_across_instances() {
274        let mut a = VecqIndex::new(64, 5);
275        let mut b = VecqIndex::new(64, 5);
276        for i in 0..10 {
277            let v = unit(64, i + 100);
278            a.add(&v);
279            b.add(&v);
280        }
281        assert_eq!(a.to_bytes(), b.to_bytes());
282    }
283
284    #[test]
285    fn rejects_garbage() {
286        assert!(matches!(
287            VecqIndex::from_bytes(b"nonsense-not-a-file-at-all"),
288            Err(Error::NotAStableFile)
289        ));
290        assert!(matches!(VecqIndex::from_bytes(&[]), Err(Error::Truncated)));
291    }
292
293    #[test]
294    fn f16_round_trip_accuracy() {
295        // Scales are ~1/sqrt(d)-ish small positives; verify decode(encode(x)) ~ x.
296        for &x in &[0.001f32, 0.03, 0.5, 1.0, 3.7, 100.0] {
297            let back = f16_bits_to_f32(f32_to_f16_bits(x));
298            assert!((back - x).abs() / x < 0.001, "{x} -> {back}");
299        }
300    }
301}