Skip to main content

velesdb_core/wire/
vrb1.rs

1//! VRB1 ("Veles Raw Bulk v1") binary wire codec.
2//!
3//! A length-prefixed, tightly-packed binary encoding of `(id, vector)` pairs
4//! for zero-copy bulk upsert, avoiding the per-point JSON overhead of the
5//! object endpoints. Payloads are not carried on this path.
6//!
7//! This is the single shared codec used by both the server raw-bulk handler
8//! and the CLI `.bin` importer — neither should re-parse the format itself.
9//!
10//! # Wire format (little-endian)
11//!
12//! ```text
13//! offset  size                     field
14//! ------  -----------------------  --------------------------------------
15//! 0       4                        magic  = b"VRB1"  (Veles Raw Bulk v1)
16//! 4       4                        count  : u32      (number of points)
17//! 8       4                        dim    : u32      (vector dimension)
18//! 12      1                        id_width : u8     (must be 8 → u64)
19//! 13      3                        reserved (must be 0) — header is 16 bytes
20//! 16      count * 8                ids    : [u64; count]
21//! 16+8c   count * dim * 4          vectors: [f32; count * dim] (row-major)
22//! ```
23//!
24//! The total length must be **exactly** `16 + count * 8 + count * dim * 4`
25//! bytes; any mismatch is an error. The encoding is deterministic: a given
26//! batch always serialises to the same bytes (see [`encode`] / [`decode`]).
27
28use std::fmt;
29
30/// 4-byte magic prefix identifying the v1 raw-bulk wire format.
31const MAGIC: &[u8; 4] = b"VRB1";
32
33/// Fixed header length: `magic`(4) + `count`(4) + `dim`(4) + `id_width`(1) + `reserved`(3).
34const HEADER_LEN: usize = 16;
35
36/// The only supported id width: `u64` ids are 8 bytes each.
37const ID_WIDTH: u8 = 8;
38
39/// A decoded VRB1 batch: owned `ids`, a flat row-major `vectors` buffer, and
40/// the declared `dimension`.
41#[derive(Debug, Clone, PartialEq)]
42pub struct RawBulk {
43    /// Point ids, in batch order.
44    pub ids: Vec<u64>,
45    /// Flat `f32` buffer of shape `(ids.len(), dimension)`, row-major.
46    pub vectors: Vec<f32>,
47    /// Declared vector dimension.
48    pub dimension: usize,
49}
50
51/// Errors produced while decoding a VRB1 body.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum VrbError {
54    /// Body shorter than the fixed 16-byte header.
55    TooShort {
56        /// Actual body length in bytes.
57        got: usize,
58    },
59    /// First four bytes are not `b"VRB1"`.
60    BadMagic,
61    /// `id_width` byte is not the supported value (8).
62    BadIdWidth(u8),
63    /// One or more reserved header bytes were non-zero.
64    ReservedNotZero,
65    /// Arithmetic overflow while computing the expected body length.
66    Overflow,
67    /// Body length does not match the length implied by `count`/`dim`.
68    LengthMismatch {
69        /// Actual body length in bytes.
70        got: usize,
71        /// Length implied by the header.
72        expected: usize,
73    },
74}
75
76impl fmt::Display for VrbError {
77    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
78        match self {
79            Self::TooShort { got } => {
80                write!(f, "body too short: {got} bytes (header needs {HEADER_LEN})")
81            }
82            Self::BadMagic => write!(f, "bad magic: expected b\"VRB1\""),
83            Self::BadIdWidth(w) => {
84                write!(
85                    f,
86                    "unsupported id_width {w}: only {ID_WIDTH} (u64) is supported"
87                )
88            }
89            Self::ReservedNotZero => write!(f, "reserved header bytes must be zero"),
90            Self::Overflow => write!(f, "overflow computing body length"),
91            Self::LengthMismatch { got, expected } => {
92                write!(f, "body length {got} != expected {expected}")
93            }
94        }
95    }
96}
97
98impl std::error::Error for VrbError {}
99
100/// Parse the fixed 16-byte header, returning `(count, dim)`.
101///
102/// Validates the magic, the id width, and the reserved padding so a malformed
103/// or wrong-version body is rejected before any allocation.
104fn parse_header(body: &[u8]) -> Result<(usize, usize), VrbError> {
105    if body.len() < HEADER_LEN {
106        return Err(VrbError::TooShort { got: body.len() });
107    }
108    if &body[0..4] != MAGIC {
109        return Err(VrbError::BadMagic);
110    }
111    let count = u32::from_le_bytes([body[4], body[5], body[6], body[7]]) as usize;
112    let dim = u32::from_le_bytes([body[8], body[9], body[10], body[11]]) as usize;
113    if body[12] != ID_WIDTH {
114        return Err(VrbError::BadIdWidth(body[12]));
115    }
116    if body[13] != 0 || body[14] != 0 || body[15] != 0 {
117        return Err(VrbError::ReservedNotZero);
118    }
119    Ok((count, dim))
120}
121
122/// Compute the expected total body length for `count` points of `dim` floats.
123///
124/// Returns [`VrbError::Overflow`] on arithmetic overflow rather than panicking.
125fn expected_body_len(count: usize, dim: usize) -> Result<usize, VrbError> {
126    let ids_bytes = count.checked_mul(8).ok_or(VrbError::Overflow)?;
127    let vec_elems = count.checked_mul(dim).ok_or(VrbError::Overflow)?;
128    let vec_bytes = vec_elems.checked_mul(4).ok_or(VrbError::Overflow)?;
129    HEADER_LEN
130        .checked_add(ids_bytes)
131        .and_then(|h| h.checked_add(vec_bytes))
132        .ok_or(VrbError::Overflow)
133}
134
135/// Decode the tightly-packed id section into a `Vec<u64>`.
136///
137/// The caller validates the exact body length first, so every 8-byte chunk is
138/// in bounds; `chunks_exact` drops any trailing partial chunk safely.
139fn decode_ids(body: &[u8], count: usize) -> Vec<u64> {
140    let start = HEADER_LEN;
141    let end = start + count * 8;
142    body[start..end]
143        .chunks_exact(8)
144        .map(|c| u64::from_le_bytes([c[0], c[1], c[2], c[3], c[4], c[5], c[6], c[7]]))
145        .collect()
146}
147
148/// Decode the tightly-packed vector section into a flat `Vec<f32>`.
149fn decode_vectors(body: &[u8], count: usize, dim: usize) -> Vec<f32> {
150    let start = HEADER_LEN + count * 8;
151    let end = start + count * dim * 4;
152    body[start..end]
153        .chunks_exact(4)
154        .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
155        .collect()
156}
157
158/// Decode a full VRB1 body into a [`RawBulk`].
159///
160/// Validates the header and the exact total length before decoding, so all
161/// slice accesses in [`decode_ids`] / [`decode_vectors`] are in bounds.
162///
163/// # Errors
164///
165/// Returns a [`VrbError`] for a too-short body, bad magic, unsupported id
166/// width, non-zero reserved bytes, length overflow, or a length mismatch.
167pub fn decode(body: &[u8]) -> Result<RawBulk, VrbError> {
168    let (count, dim) = parse_header(body)?;
169    let expected = expected_body_len(count, dim)?;
170    if body.len() != expected {
171        return Err(VrbError::LengthMismatch {
172            got: body.len(),
173            expected,
174        });
175    }
176    Ok(RawBulk {
177        ids: decode_ids(body, count),
178        vectors: decode_vectors(body, count, dim),
179        dimension: dim,
180    })
181}
182
183/// Encode an `(ids, vectors)` batch into the VRB1 wire format.
184///
185/// The inverse of [`decode`]; `vectors` is a flat row-major buffer of shape
186/// `(ids.len(), dimension)`. The caller is responsible for the invariant
187/// `vectors.len() == ids.len() * dimension`; a mismatch round-trips to a
188/// body that [`decode`] rejects with [`VrbError::LengthMismatch`].
189#[must_use]
190pub fn encode(ids: &[u64], vectors: &[f32], dimension: usize) -> Vec<u8> {
191    let count = ids.len();
192    let mut buf = Vec::with_capacity(HEADER_LEN + count * 8 + vectors.len() * 4);
193    buf.extend_from_slice(MAGIC);
194    // `count`/`dimension` exceeding u32::MAX (~4.3 billion) cannot be held in
195    // memory on this path, so the saturation is unreachable for any encodable
196    // batch and never corrupts a representable one.
197    buf.extend_from_slice(&u32::try_from(count).unwrap_or(u32::MAX).to_le_bytes());
198    buf.extend_from_slice(&u32::try_from(dimension).unwrap_or(u32::MAX).to_le_bytes());
199    buf.push(ID_WIDTH);
200    buf.extend_from_slice(&[0u8; 3]);
201    for id in ids {
202        buf.extend_from_slice(&id.to_le_bytes());
203    }
204    for v in vectors {
205        buf.extend_from_slice(&v.to_le_bytes());
206    }
207    buf
208}
209
210#[cfg(test)]
211#[allow(clippy::float_cmp)]
212mod tests {
213    use super::*;
214
215    #[test]
216    fn roundtrip_decode_encode() {
217        let ids = [1u64, 2, 3];
218        let vectors = [0.1f32, 0.2, 0.3, 0.4, 0.5, 0.6];
219        let body = encode(&ids, &vectors, 2);
220        let raw = decode(&body).expect("valid body decodes");
221        assert_eq!(raw.ids, vec![1, 2, 3]);
222        assert_eq!(raw.vectors, vec![0.1, 0.2, 0.3, 0.4, 0.5, 0.6]);
223        assert_eq!(raw.dimension, 2);
224    }
225
226    #[test]
227    fn encode_is_deterministic_and_pinned() {
228        let ids = [7u64, 42];
229        let vectors = [1.0f32, 2.0, 3.0, 4.0];
230        let a = encode(&ids, &vectors, 2);
231        let b = encode(&ids, &vectors, 2);
232        assert_eq!(a, b, "encoding must be deterministic");
233        assert_eq!(&a[0..4], b"VRB1");
234        assert_eq!(&a[4..8], &2u32.to_le_bytes());
235        assert_eq!(&a[8..12], &2u32.to_le_bytes());
236        assert_eq!(a[12], 8);
237        assert_eq!(&a[13..16], &[0, 0, 0]);
238    }
239
240    #[test]
241    fn empty_batch_roundtrips() {
242        let body = encode(&[], &[], 4);
243        let raw = decode(&body).expect("empty batch decodes");
244        assert!(raw.ids.is_empty());
245        assert!(raw.vectors.is_empty());
246        assert_eq!(raw.dimension, 4);
247    }
248
249    #[test]
250    fn bad_magic_rejected() {
251        let mut body = encode(&[1], &[0.0, 0.0], 2);
252        body[0] = b'X';
253        assert_eq!(decode(&body), Err(VrbError::BadMagic));
254    }
255
256    #[test]
257    fn short_body_rejected() {
258        let body = vec![0u8; 4];
259        assert_eq!(decode(&body), Err(VrbError::TooShort { got: 4 }));
260    }
261
262    #[test]
263    fn bad_id_width_rejected() {
264        let mut body = encode(&[1], &[0.0, 0.0], 2);
265        body[12] = 4; // u32 ids unsupported
266        assert_eq!(decode(&body), Err(VrbError::BadIdWidth(4)));
267    }
268
269    #[test]
270    fn reserved_not_zero_rejected() {
271        let mut body = encode(&[1], &[0.0, 0.0], 2);
272        body[13] = 1;
273        assert_eq!(decode(&body), Err(VrbError::ReservedNotZero));
274    }
275
276    #[test]
277    fn length_mismatch_rejected() {
278        let mut body = encode(&[1, 2], &[0.0, 0.0, 0.0, 0.0], 2);
279        body.pop(); // truncate one byte
280        match decode(&body) {
281            Err(VrbError::LengthMismatch { .. }) => {}
282            other => panic!("expected LengthMismatch, got {other:?}"),
283        }
284    }
285
286    /// A `count`/`dim` pair whose declared body length overflows `usize` is
287    /// rejected with `Overflow`, not a panic, before any allocation. The body is
288    /// only the 16-byte header; `count`/`dim` are crafted directly so the
289    /// `count * dim * 4` product blows past `usize::MAX`.
290    #[test]
291    fn overflow_count_dim_rejected() {
292        let mut body = Vec::with_capacity(HEADER_LEN);
293        body.extend_from_slice(MAGIC);
294        body.extend_from_slice(&u32::MAX.to_le_bytes()); // count
295        body.extend_from_slice(&u32::MAX.to_le_bytes()); // dim
296        body.push(ID_WIDTH);
297        body.extend_from_slice(&[0u8; 3]);
298        assert_eq!(decode(&body), Err(VrbError::Overflow));
299    }
300
301    /// Every `VrbError` variant renders a distinct, non-empty `Display` string,
302    /// and the type is usable as a `std::error::Error`.
303    #[test]
304    fn error_display_and_trait_cover_all_variants() {
305        let cases: [VrbError; 6] = [
306            VrbError::TooShort { got: 3 },
307            VrbError::BadMagic,
308            VrbError::BadIdWidth(4),
309            VrbError::ReservedNotZero,
310            VrbError::Overflow,
311            VrbError::LengthMismatch {
312                got: 10,
313                expected: 16,
314            },
315        ];
316        let rendered: Vec<String> = cases.iter().map(ToString::to_string).collect();
317        assert!(rendered.iter().all(|s| !s.is_empty()));
318        // Distinct messages per variant.
319        let unique: std::collections::HashSet<&String> = rendered.iter().collect();
320        assert_eq!(unique.len(), cases.len());
321        // A couple of pinned substrings so a future message change is visible.
322        assert!(rendered[0].contains("too short"));
323        assert!(rendered[2].contains("id_width 4"));
324        // Usable through the std error trait object.
325        let err: &dyn std::error::Error = &cases[1];
326        assert_eq!(err.to_string(), "bad magic: expected b\"VRB1\"");
327    }
328}