Squashed 'vendor/ruvector/' content from commit b64c2172
git-subtree-dir: vendor/ruvector git-subtree-split: b64c21726f2bb37286d9ee36a7869fef60cc6900
This commit is contained in:
24
crates/rvf/rvf-wire/Cargo.toml
Normal file
24
crates/rvf/rvf-wire/Cargo.toml
Normal file
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "rvf-wire"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "RuVector Format wire format reader/writer -- zero-copy segment serialization"
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://github.com/ruvnet/ruvector"
|
||||
homepage = "https://github.com/ruvnet/ruvector"
|
||||
readme = "README.md"
|
||||
categories = ["encoding", "data-structures"]
|
||||
keywords = ["vector", "database", "binary-format", "wire-protocol", "rvf"]
|
||||
rust-version = "1.87"
|
||||
|
||||
[features]
|
||||
default = ["std"]
|
||||
std = ["rvf-types/std"]
|
||||
|
||||
[dependencies]
|
||||
rvf-types = { version = "0.2.0", path = "../rvf-types" }
|
||||
xxhash-rust = { version = "0.8", features = ["xxh3"] }
|
||||
crc32c = "0.6"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
30
crates/rvf/rvf-wire/README.md
Normal file
30
crates/rvf/rvf-wire/README.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# rvf-wire
|
||||
|
||||
Zero-copy wire format reader and writer for RuVector Format (RVF) segments.
|
||||
|
||||
## Overview
|
||||
|
||||
`rvf-wire` handles serialization and deserialization of RVF binary segments:
|
||||
|
||||
- **Writer** -- append segments with automatic CRC32c and XXH3 checksums
|
||||
- **Reader** -- stream-parse segments with validation and integrity checks
|
||||
- **Zero-copy** -- borrows directly from memory-mapped buffers where possible
|
||||
|
||||
## Usage
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
rvf-wire = "0.1"
|
||||
```
|
||||
|
||||
```rust
|
||||
use rvf_wire::{SegmentWriter, SegmentReader};
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- `std` (default) -- enable `std` I/O support
|
||||
|
||||
## License
|
||||
|
||||
MIT OR Apache-2.0
|
||||
122
crates/rvf/rvf-wire/src/delta.rs
Normal file
122
crates/rvf/rvf-wire/src/delta.rs
Normal file
@@ -0,0 +1,122 @@
|
||||
//! Delta encoding with restart points for sorted integer sequences.
|
||||
//!
|
||||
//! Sorted ID sequences are delta-encoded: each value (except the first) is
|
||||
//! stored as the difference from the previous value. Every `restart_interval`
|
||||
//! entries, the value is stored absolute (not delta) to allow random access
|
||||
//! into the middle of a sequence.
|
||||
|
||||
use crate::varint::{decode_varint, encode_varint, MAX_VARINT_LEN};
|
||||
|
||||
/// Encode a sorted slice of `u64` IDs using delta-varint encoding with restart
|
||||
/// points. Appends encoded bytes to `buf`.
|
||||
///
|
||||
/// Every `restart_interval` entries (counting from 0), the full absolute value
|
||||
/// is stored. All other entries are stored as the delta from the previous value.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if `restart_interval` is 0.
|
||||
pub fn encode_delta(sorted_ids: &[u64], restart_interval: u32, buf: &mut Vec<u8>) {
|
||||
assert!(restart_interval > 0, "restart_interval must be > 0");
|
||||
let mut tmp = [0u8; MAX_VARINT_LEN];
|
||||
let mut prev = 0u64;
|
||||
for (i, &id) in sorted_ids.iter().enumerate() {
|
||||
let value = if (i as u32).is_multiple_of(restart_interval) {
|
||||
prev = id;
|
||||
id
|
||||
} else {
|
||||
let delta = id - prev;
|
||||
prev = id;
|
||||
delta
|
||||
};
|
||||
let n = encode_varint(value, &mut tmp);
|
||||
buf.extend_from_slice(&tmp[..n]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode `count` delta-varint encoded IDs from `buf`.
|
||||
///
|
||||
/// Every `restart_interval` entries the stored value is absolute; all others
|
||||
/// are deltas from the previous decoded value.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if `restart_interval` is 0 or the buffer contains insufficient data.
|
||||
pub fn decode_delta(buf: &[u8], count: usize, restart_interval: u32) -> Vec<u64> {
|
||||
assert!(restart_interval > 0, "restart_interval must be > 0");
|
||||
let mut result = Vec::with_capacity(count);
|
||||
let mut offset = 0;
|
||||
let mut prev = 0u64;
|
||||
for i in 0..count {
|
||||
let (val, consumed) =
|
||||
decode_varint(&buf[offset..]).expect("delta decode: unexpected end of data");
|
||||
offset += consumed;
|
||||
if (i as u32).is_multiple_of(restart_interval) {
|
||||
prev = val;
|
||||
} else {
|
||||
prev += val;
|
||||
}
|
||||
result.push(prev);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn round_trip_simple() {
|
||||
let ids = vec![100, 105, 108, 120, 200];
|
||||
let mut buf = Vec::new();
|
||||
encode_delta(&ids, 128, &mut buf);
|
||||
let decoded = decode_delta(&buf, ids.len(), 128);
|
||||
assert_eq!(decoded, ids);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_with_restart_points() {
|
||||
let ids: Vec<u64> = (0..20).map(|i| i * 10 + 100).collect();
|
||||
let mut buf = Vec::new();
|
||||
encode_delta(&ids, 4, &mut buf);
|
||||
let decoded = decode_delta(&buf, ids.len(), 4);
|
||||
assert_eq!(decoded, ids);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_element() {
|
||||
let ids = vec![42u64];
|
||||
let mut buf = Vec::new();
|
||||
encode_delta(&ids, 1, &mut buf);
|
||||
let decoded = decode_delta(&buf, 1, 1);
|
||||
assert_eq!(decoded, ids);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_sequence() {
|
||||
let ids: Vec<u64> = vec![];
|
||||
let mut buf = Vec::new();
|
||||
encode_delta(&ids, 8, &mut buf);
|
||||
let decoded = decode_delta(&buf, 0, 8);
|
||||
assert_eq!(decoded, ids);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restart_at_every_entry() {
|
||||
// When restart_interval=1, every value is absolute
|
||||
let ids = vec![1000, 2000, 3000, 4000];
|
||||
let mut buf = Vec::new();
|
||||
encode_delta(&ids, 1, &mut buf);
|
||||
let decoded = decode_delta(&buf, ids.len(), 1);
|
||||
assert_eq!(decoded, ids);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_values() {
|
||||
let ids = vec![u64::MAX - 100, u64::MAX - 50, u64::MAX - 10, u64::MAX];
|
||||
let mut buf = Vec::new();
|
||||
encode_delta(&ids, 128, &mut buf);
|
||||
let decoded = decode_delta(&buf, ids.len(), 128);
|
||||
assert_eq!(decoded, ids);
|
||||
}
|
||||
}
|
||||
135
crates/rvf/rvf-wire/src/hash.rs
Normal file
135
crates/rvf/rvf-wire/src/hash.rs
Normal file
@@ -0,0 +1,135 @@
|
||||
//! Hash computation and verification for RVF segments.
|
||||
//!
|
||||
//! The segment header stores a 128-bit content hash. The algorithm is
|
||||
//! identified by the `checksum_algo` field: 0=deprecated CRC32C (now
|
||||
//! upgraded to XXH3-128), 1=XXH3-128, 2=SHAKE-256 (first 128 bits).
|
||||
|
||||
use rvf_types::SegmentHeader;
|
||||
|
||||
/// Compute the XXH3-128 hash of `data`, returning a 16-byte array.
|
||||
pub fn compute_xxh3_128(data: &[u8]) -> [u8; 16] {
|
||||
let h = xxhash_rust::xxh3::xxh3_128(data);
|
||||
h.to_le_bytes()
|
||||
}
|
||||
|
||||
/// Compute the CRC32C checksum of `data`.
|
||||
pub fn compute_crc32c(data: &[u8]) -> u32 {
|
||||
crc32c::crc32c(data)
|
||||
}
|
||||
|
||||
/// Compute a 16-byte content hash field value using CRC32C.
|
||||
///
|
||||
/// The 4-byte CRC is stored in the first 4 bytes (little-endian), with the
|
||||
/// remaining 12 bytes set to zero.
|
||||
pub fn compute_crc32c_hash(data: &[u8]) -> [u8; 16] {
|
||||
let crc = compute_crc32c(data);
|
||||
let mut out = [0u8; 16];
|
||||
out[..4].copy_from_slice(&crc.to_le_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
/// Compute the content hash for a payload using the algorithm specified
|
||||
/// by `algo` (the `checksum_algo` field from the segment header).
|
||||
///
|
||||
/// - 0 = DEPRECATED CRC32C -- now upgraded to XXH3-128 for all operations.
|
||||
/// CRC32C produced only 4 bytes of entropy zero-padded to 16, making
|
||||
/// collision attacks trivial (~2^16 expected operations). All algorithms
|
||||
/// now use the full 128-bit XXH3 hash.
|
||||
/// - 1 = XXH3-128 (16 bytes)
|
||||
/// - Other values fall back to XXH3-128.
|
||||
pub fn compute_content_hash(_algo: u8, data: &[u8]) -> [u8; 16] {
|
||||
// All algorithms now use XXH3-128 for full 128-bit collision resistance.
|
||||
// algo=0 (CRC32C) is deprecated: its 32-bit output zero-padded to 128 bits
|
||||
// provided only ~32 bits of security, making collisions trivially findable.
|
||||
compute_xxh3_128(data)
|
||||
}
|
||||
|
||||
/// Verify the content hash stored in a segment header against the actual
|
||||
/// payload bytes.
|
||||
///
|
||||
/// Returns `true` if the computed hash matches `header.content_hash`.
|
||||
pub fn verify_content_hash(header: &SegmentHeader, payload: &[u8]) -> bool {
|
||||
let expected = compute_content_hash(header.checksum_algo, payload);
|
||||
expected == header.content_hash
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn xxh3_128_deterministic() {
|
||||
let data = b"hello world";
|
||||
let h1 = compute_xxh3_128(data);
|
||||
let h2 = compute_xxh3_128(data);
|
||||
assert_eq!(h1, h2);
|
||||
assert_ne!(h1, [0u8; 16]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crc32c_deterministic() {
|
||||
let data = b"hello world";
|
||||
let c1 = compute_crc32c(data);
|
||||
let c2 = compute_crc32c(data);
|
||||
assert_eq!(c1, c2);
|
||||
assert_ne!(c1, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crc32c_hash_is_zero_padded() {
|
||||
let data = b"test payload";
|
||||
let h = compute_crc32c_hash(data);
|
||||
let crc = compute_crc32c(data);
|
||||
assert_eq!(&h[..4], &crc.to_le_bytes());
|
||||
assert_eq!(&h[4..], &[0u8; 12]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_content_hash_xxh3() {
|
||||
let payload = b"some vector data";
|
||||
let hash = compute_xxh3_128(payload);
|
||||
let header = SegmentHeader {
|
||||
magic: rvf_types::SEGMENT_MAGIC,
|
||||
version: 1,
|
||||
seg_type: 0x01,
|
||||
flags: 0,
|
||||
segment_id: 1,
|
||||
payload_length: payload.len() as u64,
|
||||
timestamp_ns: 0,
|
||||
checksum_algo: 1, // XXH3-128
|
||||
compression: 0,
|
||||
reserved_0: 0,
|
||||
reserved_1: 0,
|
||||
content_hash: hash,
|
||||
uncompressed_len: 0,
|
||||
alignment_pad: 0,
|
||||
};
|
||||
assert!(verify_content_hash(&header, payload));
|
||||
assert!(!verify_content_hash(&header, b"wrong data"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_content_hash_algo_zero_uses_xxh3() {
|
||||
// algo=0 (formerly CRC32C) is now upgraded to XXH3-128, so the
|
||||
// content hash must be computed via XXH3-128 even when algo=0.
|
||||
let payload = b"crc payload";
|
||||
let hash = compute_xxh3_128(payload);
|
||||
let header = SegmentHeader {
|
||||
magic: rvf_types::SEGMENT_MAGIC,
|
||||
version: 1,
|
||||
seg_type: 0x01,
|
||||
flags: 0,
|
||||
segment_id: 2,
|
||||
payload_length: payload.len() as u64,
|
||||
timestamp_ns: 0,
|
||||
checksum_algo: 0, // deprecated CRC32C, now upgraded to XXH3-128
|
||||
compression: 0,
|
||||
reserved_0: 0,
|
||||
reserved_1: 0,
|
||||
content_hash: hash,
|
||||
uncompressed_len: 0,
|
||||
alignment_pad: 0,
|
||||
};
|
||||
assert!(verify_content_hash(&header, payload));
|
||||
}
|
||||
}
|
||||
219
crates/rvf/rvf-wire/src/hot_seg_codec.rs
Normal file
219
crates/rvf/rvf-wire/src/hot_seg_codec.rs
Normal file
@@ -0,0 +1,219 @@
|
||||
//! HOT_SEG codec.
|
||||
//!
|
||||
//! The hot segment stores the most-accessed vectors in an interleaved
|
||||
//! (row-major) layout with their neighbor lists co-located for cache
|
||||
//! locality. Each entry is 64-byte aligned.
|
||||
|
||||
/// Hot segment header, stored at the start of the HOT_SEG payload.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct HotHeader {
|
||||
pub vector_count: u32,
|
||||
pub dim: u16,
|
||||
pub dtype: u8,
|
||||
pub neighbor_m: u16,
|
||||
}
|
||||
|
||||
/// A single interleaved hot entry.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct HotEntry {
|
||||
pub vector_id: u64,
|
||||
pub vector_data: Vec<u8>,
|
||||
pub neighbor_ids: Vec<u64>,
|
||||
}
|
||||
|
||||
/// Size of an element in bytes for a given dtype.
|
||||
fn dtype_element_size(dtype: u8) -> usize {
|
||||
match dtype {
|
||||
0x00 => 4, // f32
|
||||
0x01 => 2, // f16
|
||||
0x02 => 2, // bf16
|
||||
0x03 => 1, // i8
|
||||
0x04 => 1, // u8
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
|
||||
const ALIGN: usize = 64;
|
||||
|
||||
fn align_up(n: usize) -> usize {
|
||||
(n + ALIGN - 1) & !(ALIGN - 1)
|
||||
}
|
||||
|
||||
/// Write the HOT_SEG payload from a header and a list of hot entries.
|
||||
///
|
||||
/// The header is padded to 64 bytes. Each entry is individually 64-byte
|
||||
/// aligned.
|
||||
pub fn write_hot_seg(header: &HotHeader, entries: &[HotEntry]) -> Vec<u8> {
|
||||
let mut buf = Vec::new();
|
||||
|
||||
// Hot header: vector_count(4) + dim(2) + dtype(1) + neighbor_M(2) = 9 bytes
|
||||
buf.extend_from_slice(&header.vector_count.to_le_bytes());
|
||||
buf.extend_from_slice(&header.dim.to_le_bytes());
|
||||
buf.push(header.dtype);
|
||||
buf.extend_from_slice(&header.neighbor_m.to_le_bytes());
|
||||
// Pad header to 64 bytes
|
||||
buf.resize(align_up(buf.len()), 0);
|
||||
|
||||
// Write each entry, 64-byte aligned
|
||||
for entry in entries {
|
||||
let entry_start = buf.len();
|
||||
// vector_id: u64
|
||||
buf.extend_from_slice(&entry.vector_id.to_le_bytes());
|
||||
// vector data: dtype * dim bytes
|
||||
buf.extend_from_slice(&entry.vector_data);
|
||||
// neighbor_count: u16
|
||||
let neighbor_count = entry.neighbor_ids.len() as u16;
|
||||
buf.extend_from_slice(&neighbor_count.to_le_bytes());
|
||||
// neighbor_ids: u64 * count
|
||||
for &nid in &entry.neighbor_ids {
|
||||
buf.extend_from_slice(&nid.to_le_bytes());
|
||||
}
|
||||
// Pad this entry to 64-byte alignment
|
||||
let entry_raw_size = buf.len() - entry_start;
|
||||
let entry_padded = align_up(entry_raw_size);
|
||||
buf.resize(entry_start + entry_padded, 0);
|
||||
}
|
||||
|
||||
buf
|
||||
}
|
||||
|
||||
/// Read the HOT_SEG header from the start of the payload.
|
||||
///
|
||||
/// Returns the header and the byte offset after the (64-byte aligned) header.
|
||||
pub fn read_hot_header(data: &[u8]) -> Result<(HotHeader, usize), &'static str> {
|
||||
if data.len() < 9 {
|
||||
return Err("hot header truncated");
|
||||
}
|
||||
let vector_count = u32::from_le_bytes(data[0..4].try_into().unwrap());
|
||||
let dim = u16::from_le_bytes([data[4], data[5]]);
|
||||
let dtype = data[6];
|
||||
let neighbor_m = u16::from_le_bytes([data[7], data[8]]);
|
||||
let consumed = align_up(9);
|
||||
Ok((
|
||||
HotHeader {
|
||||
vector_count,
|
||||
dim,
|
||||
dtype,
|
||||
neighbor_m,
|
||||
},
|
||||
consumed,
|
||||
))
|
||||
}
|
||||
|
||||
/// Read all hot entries from the payload (after the header).
|
||||
///
|
||||
/// `data` should start at the first entry (after the aligned header).
|
||||
pub fn read_hot_entries(data: &[u8], header: &HotHeader) -> Result<Vec<HotEntry>, &'static str> {
|
||||
let elem_size = dtype_element_size(header.dtype);
|
||||
let vector_byte_len = header.dim as usize * elem_size;
|
||||
let mut entries = Vec::with_capacity(header.vector_count as usize);
|
||||
let mut pos = 0;
|
||||
|
||||
for _ in 0..header.vector_count {
|
||||
let entry_start = pos;
|
||||
// Need at least: 8 (id) + vector_byte_len + 2 (neighbor_count)
|
||||
let min_size = 8 + vector_byte_len + 2;
|
||||
if data.len() < pos + min_size {
|
||||
return Err("hot entry truncated");
|
||||
}
|
||||
let vector_id = u64::from_le_bytes(data[pos..pos + 8].try_into().unwrap());
|
||||
pos += 8;
|
||||
let vector_data = data[pos..pos + vector_byte_len].to_vec();
|
||||
pos += vector_byte_len;
|
||||
let neighbor_count = u16::from_le_bytes([data[pos], data[pos + 1]]) as usize;
|
||||
pos += 2;
|
||||
if data.len() < pos + neighbor_count * 8 {
|
||||
return Err("neighbor IDs truncated");
|
||||
}
|
||||
let mut neighbor_ids = Vec::with_capacity(neighbor_count);
|
||||
for _ in 0..neighbor_count {
|
||||
neighbor_ids.push(u64::from_le_bytes(data[pos..pos + 8].try_into().unwrap()));
|
||||
pos += 8;
|
||||
}
|
||||
entries.push(HotEntry {
|
||||
vector_id,
|
||||
vector_data,
|
||||
neighbor_ids,
|
||||
});
|
||||
// Advance to next 64-byte boundary
|
||||
let entry_raw_size = pos - entry_start;
|
||||
pos = entry_start + align_up(entry_raw_size);
|
||||
}
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_test_entries(dim: u16, dtype: u8, count: u32, neighbor_count: usize) -> Vec<HotEntry> {
|
||||
let elem_size = dtype_element_size(dtype);
|
||||
let vector_byte_len = dim as usize * elem_size;
|
||||
(0..count)
|
||||
.map(|i| HotEntry {
|
||||
vector_id: (i as u64) * 100 + 1,
|
||||
vector_data: vec![(i % 256) as u8; vector_byte_len],
|
||||
neighbor_ids: (0..neighbor_count as u64).map(|n| n + 1000).collect(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_hot_seg() {
|
||||
let dim = 4u16;
|
||||
let dtype = 0u8; // f32
|
||||
let entries = make_test_entries(dim, dtype, 3, 2);
|
||||
let header = HotHeader {
|
||||
vector_count: 3,
|
||||
dim,
|
||||
dtype,
|
||||
neighbor_m: 16,
|
||||
};
|
||||
let payload = write_hot_seg(&header, &entries);
|
||||
|
||||
let (decoded_header, header_end) = read_hot_header(&payload).unwrap();
|
||||
assert_eq!(decoded_header, header);
|
||||
|
||||
let decoded_entries = read_hot_entries(&payload[header_end..], &decoded_header).unwrap();
|
||||
assert_eq!(decoded_entries.len(), 3);
|
||||
assert_eq!(decoded_entries[0].vector_id, 1);
|
||||
assert_eq!(decoded_entries[1].vector_id, 101);
|
||||
assert_eq!(decoded_entries[2].vector_id, 201);
|
||||
for (orig, dec) in entries.iter().zip(decoded_entries.iter()) {
|
||||
assert_eq!(orig.vector_data, dec.vector_data);
|
||||
assert_eq!(orig.neighbor_ids, dec.neighbor_ids);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_hot_seg() {
|
||||
let header = HotHeader {
|
||||
vector_count: 0,
|
||||
dim: 128,
|
||||
dtype: 1,
|
||||
neighbor_m: 16,
|
||||
};
|
||||
let payload = write_hot_seg(&header, &[]);
|
||||
let (decoded_header, header_end) = read_hot_header(&payload).unwrap();
|
||||
assert_eq!(decoded_header.vector_count, 0);
|
||||
let entries = read_hot_entries(&payload[header_end..], &decoded_header).unwrap();
|
||||
assert!(entries.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alignment_respected() {
|
||||
let dim = 384u16;
|
||||
let dtype = 1u8; // f16
|
||||
let entries = make_test_entries(dim, dtype, 2, 16);
|
||||
let header = HotHeader {
|
||||
vector_count: 2,
|
||||
dim,
|
||||
dtype,
|
||||
neighbor_m: 16,
|
||||
};
|
||||
let payload = write_hot_seg(&header, &entries);
|
||||
// Total payload should be aligned
|
||||
assert_eq!(payload.len() % 64, 0);
|
||||
}
|
||||
}
|
||||
282
crates/rvf/rvf-wire/src/index_seg_codec.rs
Normal file
282
crates/rvf/rvf-wire/src/index_seg_codec.rs
Normal file
@@ -0,0 +1,282 @@
|
||||
//! INDEX_SEG codec.
|
||||
//!
|
||||
//! Reads and writes HNSW index segments: the index header, restart point
|
||||
//! index, and adjacency data with varint delta encoding.
|
||||
|
||||
use crate::varint::{decode_varint, encode_varint, MAX_VARINT_LEN};
|
||||
|
||||
const ALIGN: usize = 64;
|
||||
|
||||
fn align_up(n: usize) -> usize {
|
||||
(n + ALIGN - 1) & !(ALIGN - 1)
|
||||
}
|
||||
|
||||
/// Index header at the start of an INDEX_SEG payload.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct IndexHeader {
|
||||
/// Index type: 0=HNSW, 1=IVF, 2=flat.
|
||||
pub index_type: u8,
|
||||
/// HNSW layer level (A=0, B=1, C=2).
|
||||
pub layer_level: u8,
|
||||
/// Maximum neighbors per layer.
|
||||
pub m: u16,
|
||||
/// HNSW ef_construction parameter.
|
||||
pub ef_construction: u32,
|
||||
/// Number of nodes in this index segment.
|
||||
pub node_count: u64,
|
||||
}
|
||||
|
||||
/// INDEX_SEG header size before padding: 1+1+2+4+8 = 16 bytes.
|
||||
const INDEX_HEADER_RAW_SIZE: usize = 16;
|
||||
|
||||
/// Restart point index.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct RestartPointIndex {
|
||||
pub restart_interval: u32,
|
||||
pub offsets: Vec<u32>,
|
||||
}
|
||||
|
||||
/// A single node's adjacency information.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct NodeAdjacency {
|
||||
/// Neighbor lists, one per layer. Each layer is a sorted list of node IDs.
|
||||
pub layers: Vec<Vec<u64>>,
|
||||
}
|
||||
|
||||
/// Write an INDEX_SEG payload from the header, restart index, and adjacency data.
|
||||
pub fn write_index_seg(
|
||||
header: &IndexHeader,
|
||||
restart: &RestartPointIndex,
|
||||
adjacency: &[NodeAdjacency],
|
||||
) -> Vec<u8> {
|
||||
let mut buf = Vec::new();
|
||||
|
||||
// Index header
|
||||
buf.push(header.index_type);
|
||||
buf.push(header.layer_level);
|
||||
buf.extend_from_slice(&header.m.to_le_bytes());
|
||||
buf.extend_from_slice(&header.ef_construction.to_le_bytes());
|
||||
buf.extend_from_slice(&header.node_count.to_le_bytes());
|
||||
buf.resize(align_up(buf.len()), 0);
|
||||
|
||||
// Restart point index
|
||||
let restart_count = restart.offsets.len() as u32;
|
||||
buf.extend_from_slice(&restart.restart_interval.to_le_bytes());
|
||||
buf.extend_from_slice(&restart_count.to_le_bytes());
|
||||
for &offset in &restart.offsets {
|
||||
buf.extend_from_slice(&offset.to_le_bytes());
|
||||
}
|
||||
buf.resize(align_up(buf.len()), 0);
|
||||
|
||||
// Adjacency data
|
||||
let mut tmp = [0u8; MAX_VARINT_LEN];
|
||||
for node in adjacency {
|
||||
let n = encode_varint(node.layers.len() as u64, &mut tmp);
|
||||
buf.extend_from_slice(&tmp[..n]);
|
||||
for layer in &node.layers {
|
||||
let n = encode_varint(layer.len() as u64, &mut tmp);
|
||||
buf.extend_from_slice(&tmp[..n]);
|
||||
// Delta-encode neighbor IDs within each layer
|
||||
let mut prev = 0u64;
|
||||
for &nid in layer {
|
||||
let delta = nid - prev;
|
||||
let n = encode_varint(delta, &mut tmp);
|
||||
buf.extend_from_slice(&tmp[..n]);
|
||||
prev = nid;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Pad to 64-byte boundary
|
||||
buf.resize(align_up(buf.len()), 0);
|
||||
|
||||
buf
|
||||
}
|
||||
|
||||
/// Read the INDEX_SEG header from the start of the payload.
|
||||
///
|
||||
/// Returns the header and the byte offset after the 64-byte aligned header.
|
||||
pub fn read_index_header(data: &[u8]) -> Result<(IndexHeader, usize), &'static str> {
|
||||
if data.len() < INDEX_HEADER_RAW_SIZE {
|
||||
return Err("index header truncated");
|
||||
}
|
||||
let header = IndexHeader {
|
||||
index_type: data[0],
|
||||
layer_level: data[1],
|
||||
m: u16::from_le_bytes([data[2], data[3]]),
|
||||
ef_construction: u32::from_le_bytes(data[4..8].try_into().unwrap()),
|
||||
node_count: u64::from_le_bytes(data[8..16].try_into().unwrap()),
|
||||
};
|
||||
Ok((header, align_up(INDEX_HEADER_RAW_SIZE)))
|
||||
}
|
||||
|
||||
/// Read the restart point index from the payload at the given offset.
|
||||
///
|
||||
/// Returns the restart index and the byte offset after it (64-byte aligned).
|
||||
pub fn read_restart_index(data: &[u8]) -> Result<(RestartPointIndex, usize), &'static str> {
|
||||
if data.len() < 8 {
|
||||
return Err("restart index truncated");
|
||||
}
|
||||
let restart_interval = u32::from_le_bytes(data[0..4].try_into().unwrap());
|
||||
let restart_count = u32::from_le_bytes(data[4..8].try_into().unwrap()) as usize;
|
||||
let offsets_end = 8 + restart_count * 4;
|
||||
if data.len() < offsets_end {
|
||||
return Err("restart offsets truncated");
|
||||
}
|
||||
let mut offsets = Vec::with_capacity(restart_count);
|
||||
for i in 0..restart_count {
|
||||
let base = 8 + i * 4;
|
||||
offsets.push(u32::from_le_bytes(data[base..base + 4].try_into().unwrap()));
|
||||
}
|
||||
let consumed = align_up(offsets_end);
|
||||
Ok((
|
||||
RestartPointIndex {
|
||||
restart_interval,
|
||||
offsets,
|
||||
},
|
||||
consumed,
|
||||
))
|
||||
}
|
||||
|
||||
/// Read adjacency data for `node_count` nodes from the payload.
|
||||
///
|
||||
/// Each node stores: layer_count(varint), then for each layer:
|
||||
/// neighbor_count(varint) followed by delta-encoded neighbor IDs (varints).
|
||||
pub fn read_adjacency(data: &[u8], node_count: u64) -> Result<Vec<NodeAdjacency>, &'static str> {
|
||||
let mut nodes = Vec::with_capacity(node_count as usize);
|
||||
let mut pos = 0;
|
||||
for _ in 0..node_count {
|
||||
let (layer_count, consumed) =
|
||||
decode_varint(&data[pos..]).map_err(|_| "adjacency layer_count decode failed")?;
|
||||
pos += consumed;
|
||||
let mut layers = Vec::with_capacity(layer_count as usize);
|
||||
for _ in 0..layer_count {
|
||||
let (neighbor_count, consumed) = decode_varint(&data[pos..])
|
||||
.map_err(|_| "adjacency neighbor_count decode failed")?;
|
||||
pos += consumed;
|
||||
let mut neighbors = Vec::with_capacity(neighbor_count as usize);
|
||||
let mut prev = 0u64;
|
||||
for _ in 0..neighbor_count {
|
||||
let (delta, consumed) =
|
||||
decode_varint(&data[pos..]).map_err(|_| "adjacency delta decode failed")?;
|
||||
pos += consumed;
|
||||
prev += delta;
|
||||
neighbors.push(prev);
|
||||
}
|
||||
layers.push(neighbors);
|
||||
}
|
||||
nodes.push(NodeAdjacency { layers });
|
||||
}
|
||||
Ok(nodes)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn index_header_round_trip() {
|
||||
let header = IndexHeader {
|
||||
index_type: 0,
|
||||
layer_level: 1,
|
||||
m: 16,
|
||||
ef_construction: 200,
|
||||
node_count: 10000,
|
||||
};
|
||||
let restart = RestartPointIndex {
|
||||
restart_interval: 64,
|
||||
offsets: vec![0, 1024, 2048],
|
||||
};
|
||||
let adjacency = vec![]; // empty for header test
|
||||
let buf = write_index_seg(&header, &restart, &adjacency);
|
||||
|
||||
let (decoded_header, header_end) = read_index_header(&buf).unwrap();
|
||||
assert_eq!(decoded_header, header);
|
||||
|
||||
let (decoded_restart, _restart_end) = read_restart_index(&buf[header_end..]).unwrap();
|
||||
assert_eq!(decoded_restart, restart);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjacency_round_trip() {
|
||||
let header = IndexHeader {
|
||||
index_type: 0,
|
||||
layer_level: 0,
|
||||
m: 8,
|
||||
ef_construction: 100,
|
||||
node_count: 3,
|
||||
};
|
||||
let restart = RestartPointIndex {
|
||||
restart_interval: 64,
|
||||
offsets: vec![0],
|
||||
};
|
||||
let adjacency = vec![
|
||||
NodeAdjacency {
|
||||
layers: vec![vec![10, 20, 30]],
|
||||
},
|
||||
NodeAdjacency {
|
||||
layers: vec![vec![5, 15, 25, 35]],
|
||||
},
|
||||
NodeAdjacency {
|
||||
layers: vec![vec![100, 200], vec![50]],
|
||||
},
|
||||
];
|
||||
|
||||
let buf = write_index_seg(&header, &restart, &adjacency);
|
||||
let (_, header_end) = read_index_header(&buf).unwrap();
|
||||
let (_, restart_end) = read_restart_index(&buf[header_end..]).unwrap();
|
||||
let adj_data = &buf[header_end + restart_end..];
|
||||
|
||||
let decoded = read_adjacency(adj_data, 3).unwrap();
|
||||
assert_eq!(decoded.len(), 3);
|
||||
assert_eq!(decoded[0].layers[0], vec![10, 20, 30]);
|
||||
assert_eq!(decoded[1].layers[0], vec![5, 15, 25, 35]);
|
||||
assert_eq!(decoded[2].layers[0], vec![100, 200]);
|
||||
assert_eq!(decoded[2].layers[1], vec![50]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_index() {
|
||||
let header = IndexHeader {
|
||||
index_type: 2,
|
||||
layer_level: 0,
|
||||
m: 0,
|
||||
ef_construction: 0,
|
||||
node_count: 0,
|
||||
};
|
||||
let restart = RestartPointIndex {
|
||||
restart_interval: 1,
|
||||
offsets: vec![],
|
||||
};
|
||||
let buf = write_index_seg(&header, &restart, &[]);
|
||||
|
||||
let (decoded_header, header_end) = read_index_header(&buf).unwrap();
|
||||
assert_eq!(decoded_header.node_count, 0);
|
||||
|
||||
let (decoded_restart, restart_end) = read_restart_index(&buf[header_end..]).unwrap();
|
||||
assert!(decoded_restart.offsets.is_empty());
|
||||
|
||||
let adj_data = &buf[header_end + restart_end..];
|
||||
let decoded_adj = read_adjacency(adj_data, 0).unwrap();
|
||||
assert!(decoded_adj.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alignment() {
|
||||
let header = IndexHeader {
|
||||
index_type: 0,
|
||||
layer_level: 0,
|
||||
m: 16,
|
||||
ef_construction: 200,
|
||||
node_count: 1,
|
||||
};
|
||||
let restart = RestartPointIndex {
|
||||
restart_interval: 64,
|
||||
offsets: vec![0],
|
||||
};
|
||||
let adjacency = vec![NodeAdjacency {
|
||||
layers: vec![vec![1, 2, 3, 4, 5]],
|
||||
}];
|
||||
let buf = write_index_seg(&header, &restart, &adjacency);
|
||||
assert_eq!(buf.len() % 64, 0);
|
||||
}
|
||||
}
|
||||
20
crates/rvf/rvf-wire/src/lib.rs
Normal file
20
crates/rvf/rvf-wire/src/lib.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
//! RVF wire format reader/writer.
|
||||
//!
|
||||
//! This crate implements the binary encoding and decoding for the RuVector
|
||||
//! Format (RVF): segment headers, varint encoding, delta coding, hash
|
||||
//! computation, tail scanning, and per-segment-type codecs.
|
||||
|
||||
pub mod delta;
|
||||
pub mod hash;
|
||||
pub mod hot_seg_codec;
|
||||
pub mod index_seg_codec;
|
||||
pub mod manifest_codec;
|
||||
pub mod reader;
|
||||
pub mod tail_scan;
|
||||
pub mod varint;
|
||||
pub mod vec_seg_codec;
|
||||
pub mod writer;
|
||||
|
||||
pub use reader::{read_segment, read_segment_header, validate_segment};
|
||||
pub use tail_scan::find_latest_manifest;
|
||||
pub use writer::{calculate_padded_size, write_segment};
|
||||
294
crates/rvf/rvf-wire/src/manifest_codec.rs
Normal file
294
crates/rvf/rvf-wire/src/manifest_codec.rs
Normal file
@@ -0,0 +1,294 @@
|
||||
//! Level 0 root manifest codec.
|
||||
//!
|
||||
//! The root manifest is always exactly 4096 bytes, found at the tail of the
|
||||
//! file (or at the tail of a MANIFEST_SEG payload). It contains hotset
|
||||
//! pointers for instant boot and a CRC32C checksum at the last 4 bytes.
|
||||
|
||||
use crate::hash::compute_crc32c;
|
||||
use rvf_types::{ErrorCode, RvfError, ROOT_MANIFEST_MAGIC, ROOT_MANIFEST_SIZE};
|
||||
|
||||
/// Parsed Level 0 root manifest.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Level0Root {
|
||||
pub magic: u32,
|
||||
pub version: u16,
|
||||
pub flags: u16,
|
||||
pub l1_manifest_offset: u64,
|
||||
pub l1_manifest_length: u64,
|
||||
pub total_vector_count: u64,
|
||||
pub dimension: u16,
|
||||
pub base_dtype: u8,
|
||||
pub profile_id: u8,
|
||||
pub epoch: u32,
|
||||
pub created_ns: u64,
|
||||
pub modified_ns: u64,
|
||||
// Hotset pointers
|
||||
pub entrypoint_seg_offset: u64,
|
||||
pub entrypoint_block_offset: u32,
|
||||
pub entrypoint_count: u32,
|
||||
pub toplayer_seg_offset: u64,
|
||||
pub toplayer_block_offset: u32,
|
||||
pub toplayer_node_count: u32,
|
||||
pub centroid_seg_offset: u64,
|
||||
pub centroid_block_offset: u32,
|
||||
pub centroid_count: u32,
|
||||
pub quantdict_seg_offset: u64,
|
||||
pub quantdict_block_offset: u32,
|
||||
pub quantdict_size: u32,
|
||||
pub hot_cache_seg_offset: u64,
|
||||
pub hot_cache_block_offset: u32,
|
||||
pub hot_cache_vector_count: u32,
|
||||
pub prefetch_map_offset: u64,
|
||||
pub prefetch_map_entries: u32,
|
||||
// Checksum
|
||||
pub root_checksum: u32,
|
||||
}
|
||||
|
||||
impl Default for Level0Root {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
magic: ROOT_MANIFEST_MAGIC,
|
||||
version: 1,
|
||||
flags: 0,
|
||||
l1_manifest_offset: 0,
|
||||
l1_manifest_length: 0,
|
||||
total_vector_count: 0,
|
||||
dimension: 0,
|
||||
base_dtype: 0,
|
||||
profile_id: 0,
|
||||
epoch: 0,
|
||||
created_ns: 0,
|
||||
modified_ns: 0,
|
||||
entrypoint_seg_offset: 0,
|
||||
entrypoint_block_offset: 0,
|
||||
entrypoint_count: 0,
|
||||
toplayer_seg_offset: 0,
|
||||
toplayer_block_offset: 0,
|
||||
toplayer_node_count: 0,
|
||||
centroid_seg_offset: 0,
|
||||
centroid_block_offset: 0,
|
||||
centroid_count: 0,
|
||||
quantdict_seg_offset: 0,
|
||||
quantdict_block_offset: 0,
|
||||
quantdict_size: 0,
|
||||
hot_cache_seg_offset: 0,
|
||||
hot_cache_block_offset: 0,
|
||||
hot_cache_vector_count: 0,
|
||||
prefetch_map_offset: 0,
|
||||
prefetch_map_entries: 0,
|
||||
root_checksum: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_u16_le(data: &[u8], offset: usize) -> u16 {
|
||||
u16::from_le_bytes([data[offset], data[offset + 1]])
|
||||
}
|
||||
|
||||
fn read_u32_le(data: &[u8], offset: usize) -> u32 {
|
||||
u32::from_le_bytes(data[offset..offset + 4].try_into().unwrap())
|
||||
}
|
||||
|
||||
fn read_u64_le(data: &[u8], offset: usize) -> u64 {
|
||||
u64::from_le_bytes(data[offset..offset + 8].try_into().unwrap())
|
||||
}
|
||||
|
||||
fn write_u16_le(buf: &mut [u8], offset: usize, val: u16) {
|
||||
buf[offset..offset + 2].copy_from_slice(&val.to_le_bytes());
|
||||
}
|
||||
|
||||
fn write_u32_le(buf: &mut [u8], offset: usize, val: u32) {
|
||||
buf[offset..offset + 4].copy_from_slice(&val.to_le_bytes());
|
||||
}
|
||||
|
||||
fn write_u64_le(buf: &mut [u8], offset: usize, val: u64) {
|
||||
buf[offset..offset + 8].copy_from_slice(&val.to_le_bytes());
|
||||
}
|
||||
|
||||
/// Read and parse a Level 0 root manifest from a 4096-byte slice.
|
||||
///
|
||||
/// Validates the magic (`RVM0`) and CRC32C checksum.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - `InvalidManifest` if the magic is wrong or the checksum doesn't match.
|
||||
/// - `TruncatedSegment` if `data` is shorter than 4096 bytes.
|
||||
pub fn read_root_manifest(data: &[u8]) -> Result<Level0Root, RvfError> {
|
||||
if data.len() < ROOT_MANIFEST_SIZE {
|
||||
return Err(RvfError::Code(ErrorCode::TruncatedSegment));
|
||||
}
|
||||
|
||||
let magic = read_u32_le(data, 0x000);
|
||||
if magic != ROOT_MANIFEST_MAGIC {
|
||||
return Err(RvfError::Code(ErrorCode::InvalidManifest));
|
||||
}
|
||||
|
||||
// Verify CRC32C: checksum covers bytes 0x000..0xFFC
|
||||
let stored_checksum = read_u32_le(data, 0xFFC);
|
||||
let computed_checksum = compute_crc32c(&data[..0xFFC]);
|
||||
if stored_checksum != computed_checksum {
|
||||
return Err(RvfError::Code(ErrorCode::InvalidChecksum));
|
||||
}
|
||||
|
||||
Ok(Level0Root {
|
||||
magic,
|
||||
version: read_u16_le(data, 0x004),
|
||||
flags: read_u16_le(data, 0x006),
|
||||
l1_manifest_offset: read_u64_le(data, 0x008),
|
||||
l1_manifest_length: read_u64_le(data, 0x010),
|
||||
total_vector_count: read_u64_le(data, 0x018),
|
||||
dimension: read_u16_le(data, 0x020),
|
||||
base_dtype: data[0x022],
|
||||
profile_id: data[0x023],
|
||||
epoch: read_u32_le(data, 0x024),
|
||||
created_ns: read_u64_le(data, 0x028),
|
||||
modified_ns: read_u64_le(data, 0x030),
|
||||
// Hotset pointers
|
||||
entrypoint_seg_offset: read_u64_le(data, 0x038),
|
||||
entrypoint_block_offset: read_u32_le(data, 0x040),
|
||||
entrypoint_count: read_u32_le(data, 0x044),
|
||||
toplayer_seg_offset: read_u64_le(data, 0x048),
|
||||
toplayer_block_offset: read_u32_le(data, 0x050),
|
||||
toplayer_node_count: read_u32_le(data, 0x054),
|
||||
centroid_seg_offset: read_u64_le(data, 0x058),
|
||||
centroid_block_offset: read_u32_le(data, 0x060),
|
||||
centroid_count: read_u32_le(data, 0x064),
|
||||
quantdict_seg_offset: read_u64_le(data, 0x068),
|
||||
quantdict_block_offset: read_u32_le(data, 0x070),
|
||||
quantdict_size: read_u32_le(data, 0x074),
|
||||
hot_cache_seg_offset: read_u64_le(data, 0x078),
|
||||
hot_cache_block_offset: read_u32_le(data, 0x080),
|
||||
hot_cache_vector_count: read_u32_le(data, 0x084),
|
||||
prefetch_map_offset: read_u64_le(data, 0x088),
|
||||
prefetch_map_entries: read_u32_le(data, 0x090),
|
||||
root_checksum: stored_checksum,
|
||||
})
|
||||
}
|
||||
|
||||
/// Serialize a Level 0 root manifest into a 4096-byte array.
|
||||
///
|
||||
/// Computes and stores the CRC32C checksum at offset 0xFFC.
|
||||
pub fn write_root_manifest(root: &Level0Root) -> [u8; ROOT_MANIFEST_SIZE] {
|
||||
let mut buf = [0u8; ROOT_MANIFEST_SIZE];
|
||||
|
||||
write_u32_le(&mut buf, 0x000, root.magic);
|
||||
write_u16_le(&mut buf, 0x004, root.version);
|
||||
write_u16_le(&mut buf, 0x006, root.flags);
|
||||
write_u64_le(&mut buf, 0x008, root.l1_manifest_offset);
|
||||
write_u64_le(&mut buf, 0x010, root.l1_manifest_length);
|
||||
write_u64_le(&mut buf, 0x018, root.total_vector_count);
|
||||
write_u16_le(&mut buf, 0x020, root.dimension);
|
||||
buf[0x022] = root.base_dtype;
|
||||
buf[0x023] = root.profile_id;
|
||||
write_u32_le(&mut buf, 0x024, root.epoch);
|
||||
write_u64_le(&mut buf, 0x028, root.created_ns);
|
||||
write_u64_le(&mut buf, 0x030, root.modified_ns);
|
||||
// Hotset pointers
|
||||
write_u64_le(&mut buf, 0x038, root.entrypoint_seg_offset);
|
||||
write_u32_le(&mut buf, 0x040, root.entrypoint_block_offset);
|
||||
write_u32_le(&mut buf, 0x044, root.entrypoint_count);
|
||||
write_u64_le(&mut buf, 0x048, root.toplayer_seg_offset);
|
||||
write_u32_le(&mut buf, 0x050, root.toplayer_block_offset);
|
||||
write_u32_le(&mut buf, 0x054, root.toplayer_node_count);
|
||||
write_u64_le(&mut buf, 0x058, root.centroid_seg_offset);
|
||||
write_u32_le(&mut buf, 0x060, root.centroid_block_offset);
|
||||
write_u32_le(&mut buf, 0x064, root.centroid_count);
|
||||
write_u64_le(&mut buf, 0x068, root.quantdict_seg_offset);
|
||||
write_u32_le(&mut buf, 0x070, root.quantdict_block_offset);
|
||||
write_u32_le(&mut buf, 0x074, root.quantdict_size);
|
||||
write_u64_le(&mut buf, 0x078, root.hot_cache_seg_offset);
|
||||
write_u32_le(&mut buf, 0x080, root.hot_cache_block_offset);
|
||||
write_u32_le(&mut buf, 0x084, root.hot_cache_vector_count);
|
||||
write_u64_le(&mut buf, 0x088, root.prefetch_map_offset);
|
||||
write_u32_le(&mut buf, 0x090, root.prefetch_map_entries);
|
||||
|
||||
// Compute and write CRC32C
|
||||
let checksum = compute_crc32c(&buf[..0xFFC]);
|
||||
write_u32_le(&mut buf, 0xFFC, checksum);
|
||||
|
||||
buf
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn round_trip_default() {
|
||||
let root = Level0Root::default();
|
||||
let buf = write_root_manifest(&root);
|
||||
assert_eq!(buf.len(), ROOT_MANIFEST_SIZE);
|
||||
let decoded = read_root_manifest(&buf).unwrap();
|
||||
assert_eq!(decoded.magic, ROOT_MANIFEST_MAGIC);
|
||||
assert_eq!(decoded.version, 1);
|
||||
assert_eq!(decoded.total_vector_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_with_values() {
|
||||
let root = Level0Root {
|
||||
magic: ROOT_MANIFEST_MAGIC,
|
||||
version: 1,
|
||||
flags: 0,
|
||||
l1_manifest_offset: 4096,
|
||||
l1_manifest_length: 2048,
|
||||
total_vector_count: 1_000_000,
|
||||
dimension: 384,
|
||||
base_dtype: 1, // f16
|
||||
profile_id: 2, // text
|
||||
epoch: 42,
|
||||
created_ns: 1700000000000000000,
|
||||
modified_ns: 1700000001000000000,
|
||||
entrypoint_seg_offset: 8192,
|
||||
entrypoint_block_offset: 64,
|
||||
entrypoint_count: 10,
|
||||
toplayer_seg_offset: 16384,
|
||||
toplayer_block_offset: 128,
|
||||
toplayer_node_count: 100,
|
||||
centroid_seg_offset: 32768,
|
||||
centroid_block_offset: 0,
|
||||
centroid_count: 256,
|
||||
quantdict_seg_offset: 65536,
|
||||
quantdict_block_offset: 0,
|
||||
quantdict_size: 4096,
|
||||
hot_cache_seg_offset: 131072,
|
||||
hot_cache_block_offset: 0,
|
||||
hot_cache_vector_count: 1000,
|
||||
prefetch_map_offset: 262144,
|
||||
prefetch_map_entries: 50,
|
||||
root_checksum: 0, // will be computed
|
||||
};
|
||||
let buf = write_root_manifest(&root);
|
||||
let decoded = read_root_manifest(&buf).unwrap();
|
||||
assert_eq!(decoded.total_vector_count, 1_000_000);
|
||||
assert_eq!(decoded.dimension, 384);
|
||||
assert_eq!(decoded.base_dtype, 1);
|
||||
assert_eq!(decoded.epoch, 42);
|
||||
assert_eq!(decoded.entrypoint_count, 10);
|
||||
assert_eq!(decoded.hot_cache_vector_count, 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_magic_rejected() {
|
||||
let mut buf = [0u8; ROOT_MANIFEST_SIZE];
|
||||
buf[0..4].copy_from_slice(&0xDEADBEEFu32.to_le_bytes());
|
||||
let result = read_root_manifest(&buf);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupted_checksum_rejected() {
|
||||
let root = Level0Root::default();
|
||||
let mut buf = write_root_manifest(&root);
|
||||
// Corrupt one byte in the data area
|
||||
buf[0x020] ^= 0xFF;
|
||||
let result = read_root_manifest(&buf);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_data_rejected() {
|
||||
let result = read_root_manifest(&[0u8; 100]);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
174
crates/rvf/rvf-wire/src/reader.rs
Normal file
174
crates/rvf/rvf-wire/src/reader.rs
Normal file
@@ -0,0 +1,174 @@
|
||||
//! Segment header reader and validator.
|
||||
//!
|
||||
//! Reads the fixed 64-byte segment header from a byte slice, validates
|
||||
//! magic and version fields, and optionally verifies the content hash.
|
||||
|
||||
use crate::hash::verify_content_hash;
|
||||
use rvf_types::{
|
||||
ErrorCode, RvfError, SegmentHeader, SEGMENT_HEADER_SIZE, SEGMENT_MAGIC, SEGMENT_VERSION,
|
||||
};
|
||||
|
||||
/// Read and parse a segment header from the first 64 bytes of `data`.
|
||||
///
|
||||
/// Validates the magic number and format version. Does not verify the
|
||||
/// content hash (use `validate_segment` for that).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - `InvalidMagic` if the magic number does not match `RVFS`.
|
||||
/// - `InvalidVersion` if the version is not supported.
|
||||
/// - `TruncatedSegment` if `data` is shorter than 64 bytes.
|
||||
pub fn read_segment_header(data: &[u8]) -> Result<SegmentHeader, RvfError> {
|
||||
if data.len() < SEGMENT_HEADER_SIZE {
|
||||
return Err(RvfError::Code(ErrorCode::TruncatedSegment));
|
||||
}
|
||||
|
||||
let magic = u32::from_le_bytes([data[0], data[1], data[2], data[3]]);
|
||||
if magic != SEGMENT_MAGIC {
|
||||
return Err(RvfError::BadMagic {
|
||||
expected: SEGMENT_MAGIC,
|
||||
got: magic,
|
||||
});
|
||||
}
|
||||
|
||||
let version = data[4];
|
||||
if version != SEGMENT_VERSION {
|
||||
return Err(RvfError::Code(ErrorCode::InvalidVersion));
|
||||
}
|
||||
|
||||
let seg_type = data[5];
|
||||
let flags = u16::from_le_bytes([data[6], data[7]]);
|
||||
let segment_id = u64::from_le_bytes(data[0x08..0x10].try_into().unwrap());
|
||||
let payload_length = u64::from_le_bytes(data[0x10..0x18].try_into().unwrap());
|
||||
let timestamp_ns = u64::from_le_bytes(data[0x18..0x20].try_into().unwrap());
|
||||
let checksum_algo = data[0x20];
|
||||
let compression = data[0x21];
|
||||
let reserved_0 = u16::from_le_bytes([data[0x22], data[0x23]]);
|
||||
let reserved_1 = u32::from_le_bytes(data[0x24..0x28].try_into().unwrap());
|
||||
let mut content_hash = [0u8; 16];
|
||||
content_hash.copy_from_slice(&data[0x28..0x38]);
|
||||
let uncompressed_len = u32::from_le_bytes(data[0x38..0x3C].try_into().unwrap());
|
||||
let alignment_pad = u32::from_le_bytes(data[0x3C..0x40].try_into().unwrap());
|
||||
|
||||
Ok(SegmentHeader {
|
||||
magic,
|
||||
version,
|
||||
seg_type,
|
||||
flags,
|
||||
segment_id,
|
||||
payload_length,
|
||||
timestamp_ns,
|
||||
checksum_algo,
|
||||
compression,
|
||||
reserved_0,
|
||||
reserved_1,
|
||||
content_hash,
|
||||
uncompressed_len,
|
||||
alignment_pad,
|
||||
})
|
||||
}
|
||||
|
||||
/// Validate the content hash of a segment.
|
||||
///
|
||||
/// Computes the hash of `payload` using the algorithm specified in
|
||||
/// `header.checksum_algo` and compares it against `header.content_hash`.
|
||||
///
|
||||
/// The SEALED flag (0x0008) is intentionally NOT treated as a bypass for hash
|
||||
/// verification. A segment being sealed only means it was verified during
|
||||
/// compaction, but an attacker could set the flag on a corrupted segment to
|
||||
/// skip validation. Always verify the content hash regardless of flags.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - `InvalidChecksum` if the computed hash does not match.
|
||||
pub fn validate_segment(header: &SegmentHeader, payload: &[u8]) -> Result<(), RvfError> {
|
||||
// Always verify the content hash. The SEALED flag is not a reason to skip
|
||||
// verification -- an attacker could set the flag on a tampered segment to
|
||||
// bypass integrity checks.
|
||||
if !verify_content_hash(header, payload) {
|
||||
return Err(RvfError::Code(ErrorCode::InvalidChecksum));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read a complete segment: header + payload slice.
|
||||
///
|
||||
/// Returns the parsed header and a sub-slice of `data` containing the
|
||||
/// payload bytes. The payload slice starts at offset 64 and extends for
|
||||
/// `header.payload_length` bytes.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - Any error from `read_segment_header`.
|
||||
/// - `TruncatedSegment` if `data` does not contain enough bytes for the
|
||||
/// declared payload length.
|
||||
pub fn read_segment(data: &[u8]) -> Result<(SegmentHeader, &[u8]), RvfError> {
|
||||
let header = read_segment_header(data)?;
|
||||
let payload_end = SEGMENT_HEADER_SIZE + header.payload_length as usize;
|
||||
if data.len() < payload_end {
|
||||
return Err(RvfError::Code(ErrorCode::TruncatedSegment));
|
||||
}
|
||||
let payload = &data[SEGMENT_HEADER_SIZE..payload_end];
|
||||
Ok((header, payload))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::writer::write_segment;
|
||||
use rvf_types::{SegmentFlags, SegmentType};
|
||||
|
||||
#[test]
|
||||
fn read_write_round_trip() {
|
||||
let payload = b"hello vector world";
|
||||
let flags = SegmentFlags::empty();
|
||||
let seg = write_segment(SegmentType::Vec as u8, payload, flags, 42);
|
||||
let (header, decoded_payload) = read_segment(&seg).unwrap();
|
||||
assert_eq!(header.magic, SEGMENT_MAGIC);
|
||||
assert_eq!(header.version, SEGMENT_VERSION);
|
||||
assert_eq!(header.seg_type, SegmentType::Vec as u8);
|
||||
assert_eq!(header.segment_id, 42);
|
||||
assert_eq!(decoded_payload, payload);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_segment_succeeds() {
|
||||
let payload = b"data for validation";
|
||||
let flags = SegmentFlags::empty();
|
||||
let seg = write_segment(SegmentType::Index as u8, payload, flags, 1);
|
||||
let (header, decoded_payload) = read_segment(&seg).unwrap();
|
||||
assert!(validate_segment(&header, decoded_payload).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_segment_detects_corruption() {
|
||||
let payload = b"original data";
|
||||
let flags = SegmentFlags::empty();
|
||||
let seg = write_segment(SegmentType::Vec as u8, payload, flags, 1);
|
||||
let (header, _) = read_segment(&seg).unwrap();
|
||||
assert!(validate_segment(&header, b"corrupted data").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_header_returns_error() {
|
||||
let result = read_segment_header(&[0u8; 32]);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_magic_returns_error() {
|
||||
let mut data = [0u8; 64];
|
||||
data[0..4].copy_from_slice(&0xDEADBEEFu32.to_le_bytes());
|
||||
let result = read_segment_header(&data);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_version_returns_error() {
|
||||
let mut data = [0u8; 64];
|
||||
data[0..4].copy_from_slice(&SEGMENT_MAGIC.to_le_bytes());
|
||||
data[4] = 99; // bad version
|
||||
let result = read_segment_header(&data);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
172
crates/rvf/rvf-wire/src/tail_scan.rs
Normal file
172
crates/rvf/rvf-wire/src/tail_scan.rs
Normal file
@@ -0,0 +1,172 @@
|
||||
//! Tail-scan algorithm for finding the latest manifest segment.
|
||||
//!
|
||||
//! An RVF file is discovered from its tail: the Level 0 root manifest is
|
||||
//! always the last 4096 bytes. If that's invalid, we scan backward at
|
||||
//! 64-byte boundaries looking for a MANIFEST_SEG header.
|
||||
|
||||
use crate::reader::read_segment_header;
|
||||
use rvf_types::{
|
||||
ErrorCode, RvfError, SegmentHeader, SegmentType, ROOT_MANIFEST_MAGIC, ROOT_MANIFEST_SIZE,
|
||||
SEGMENT_ALIGNMENT, SEGMENT_HEADER_SIZE, SEGMENT_MAGIC, SEGMENT_VERSION,
|
||||
};
|
||||
|
||||
/// Find the latest manifest segment in `data` by scanning from the tail.
|
||||
///
|
||||
/// **Fast path**: check the last 4096 bytes for the root manifest magic
|
||||
/// (`RVM0`). If valid, scan backward from that point for the enclosing
|
||||
/// MANIFEST_SEG header.
|
||||
///
|
||||
/// **Slow path**: scan backward from the end of `data` at 64-byte aligned
|
||||
/// boundaries, looking for a segment header with magic `RVFS` and type
|
||||
/// `MANIFEST_SEG` (0x05).
|
||||
///
|
||||
/// Returns `(byte_offset, SegmentHeader)` of the manifest segment.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - `ManifestNotFound` if no valid MANIFEST_SEG is found in the entire file.
|
||||
/// - `TruncatedSegment` if the file is too short to contain any segment.
|
||||
pub fn find_latest_manifest(data: &[u8]) -> Result<(usize, SegmentHeader), RvfError> {
|
||||
if data.len() < SEGMENT_HEADER_SIZE {
|
||||
return Err(RvfError::Code(ErrorCode::TruncatedSegment));
|
||||
}
|
||||
|
||||
// Fast path: check last 4096 bytes for RVM0 magic
|
||||
if data.len() >= ROOT_MANIFEST_SIZE {
|
||||
let root_start = data.len() - ROOT_MANIFEST_SIZE;
|
||||
let root_slice = &data[root_start..];
|
||||
if root_slice.len() >= 4 {
|
||||
let root_magic =
|
||||
u32::from_le_bytes([root_slice[0], root_slice[1], root_slice[2], root_slice[3]]);
|
||||
if root_magic == ROOT_MANIFEST_MAGIC {
|
||||
// Scan backward from root_start for the enclosing MANIFEST_SEG header
|
||||
let scan_limit = root_start.saturating_sub(64 * 1024);
|
||||
let mut scan_pos = root_start & !(SEGMENT_ALIGNMENT - 1);
|
||||
loop {
|
||||
if scan_pos + SEGMENT_HEADER_SIZE <= data.len() {
|
||||
if let Ok(header) = read_segment_header(&data[scan_pos..]) {
|
||||
if header.seg_type == SegmentType::Manifest as u8 {
|
||||
let seg_end =
|
||||
scan_pos + SEGMENT_HEADER_SIZE + header.payload_length as usize;
|
||||
if seg_end >= root_start + ROOT_MANIFEST_SIZE
|
||||
|| seg_end >= data.len()
|
||||
{
|
||||
return Ok((scan_pos, header));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if scan_pos <= scan_limit || scan_pos == 0 {
|
||||
break;
|
||||
}
|
||||
scan_pos = scan_pos.saturating_sub(SEGMENT_ALIGNMENT);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Slow path: scan backward using the first magic byte ('R' = 0x52) as
|
||||
// an anchor. On aligned boundaries within the data, we search for the
|
||||
// magic byte first (memchr-style) to skip large runs of non-magic data,
|
||||
// then verify the full 4-byte magic + version + type.
|
||||
let magic_le = SEGMENT_MAGIC.to_le_bytes();
|
||||
let magic_first = magic_le[0]; // 'R' = 0x52
|
||||
let last_aligned = (data.len().saturating_sub(SEGMENT_ALIGNMENT)) & !(SEGMENT_ALIGNMENT - 1);
|
||||
let mut scan_pos = last_aligned;
|
||||
loop {
|
||||
if scan_pos + SEGMENT_HEADER_SIZE <= data.len() {
|
||||
// Quick rejection: check first byte of magic before doing full comparison.
|
||||
if data[scan_pos] == magic_first {
|
||||
let magic = u32::from_le_bytes([
|
||||
data[scan_pos],
|
||||
data[scan_pos + 1],
|
||||
data[scan_pos + 2],
|
||||
data[scan_pos + 3],
|
||||
]);
|
||||
if magic == SEGMENT_MAGIC
|
||||
&& data[scan_pos + 4] == SEGMENT_VERSION
|
||||
&& data[scan_pos + 5] == SegmentType::Manifest as u8
|
||||
{
|
||||
if let Ok(header) = read_segment_header(&data[scan_pos..]) {
|
||||
return Ok((scan_pos, header));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if scan_pos == 0 {
|
||||
break;
|
||||
}
|
||||
scan_pos -= SEGMENT_ALIGNMENT;
|
||||
}
|
||||
|
||||
Err(RvfError::Code(ErrorCode::ManifestNotFound))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::writer::write_segment;
|
||||
use rvf_types::SegmentFlags;
|
||||
|
||||
fn make_manifest_segment(segment_id: u64, payload: &[u8]) -> Vec<u8> {
|
||||
write_segment(
|
||||
SegmentType::Manifest as u8,
|
||||
payload,
|
||||
SegmentFlags::empty(),
|
||||
segment_id,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_single_manifest() {
|
||||
let vec_seg = write_segment(
|
||||
SegmentType::Vec as u8,
|
||||
&[1u8; 100],
|
||||
SegmentFlags::empty(),
|
||||
0,
|
||||
);
|
||||
let manifest_payload = vec![0u8; 64];
|
||||
let manifest_seg = make_manifest_segment(1, &manifest_payload);
|
||||
let mut file = vec_seg.clone();
|
||||
let manifest_offset = file.len();
|
||||
file.extend_from_slice(&manifest_seg);
|
||||
|
||||
let (offset, header) = find_latest_manifest(&file).unwrap();
|
||||
assert_eq!(offset, manifest_offset);
|
||||
assert_eq!(header.seg_type, SegmentType::Manifest as u8);
|
||||
assert_eq!(header.segment_id, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_latest_of_multiple_manifests() {
|
||||
let vec_seg = write_segment(SegmentType::Vec as u8, &[0u8; 32], SegmentFlags::empty(), 0);
|
||||
let m1 = make_manifest_segment(1, &[0u8; 32]);
|
||||
let m2 = make_manifest_segment(2, &[0u8; 32]);
|
||||
let mut file = vec_seg;
|
||||
file.extend_from_slice(&m1);
|
||||
let m2_offset = file.len();
|
||||
file.extend_from_slice(&m2);
|
||||
|
||||
let (offset, header) = find_latest_manifest(&file).unwrap();
|
||||
assert_eq!(offset, m2_offset);
|
||||
assert_eq!(header.segment_id, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_manifest_returns_error() {
|
||||
let vec_seg = write_segment(
|
||||
SegmentType::Vec as u8,
|
||||
&[0u8; 100],
|
||||
SegmentFlags::empty(),
|
||||
0,
|
||||
);
|
||||
let result = find_latest_manifest(&vec_seg);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn too_short_returns_error() {
|
||||
let result = find_latest_manifest(&[0u8; 10]);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
150
crates/rvf/rvf-wire/src/varint.rs
Normal file
150
crates/rvf/rvf-wire/src/varint.rs
Normal file
@@ -0,0 +1,150 @@
|
||||
//! LEB128 unsigned varint encoding and decoding.
|
||||
//!
|
||||
//! Values up to `u64::MAX` are encoded in 1-10 bytes. Each byte uses the
|
||||
//! high bit as a continuation flag: `1` means more bytes follow, `0` means
|
||||
//! this is the last byte. The remaining 7 bits contribute to the value
|
||||
//! in little-endian order.
|
||||
|
||||
use rvf_types::{ErrorCode, RvfError};
|
||||
|
||||
/// Maximum number of bytes a u64 varint can occupy.
|
||||
pub const MAX_VARINT_LEN: usize = 10;
|
||||
|
||||
/// Encode a `u64` value as a LEB128 varint into `buf`.
|
||||
///
|
||||
/// Returns the number of bytes written. The caller must ensure `buf` is at
|
||||
/// least `MAX_VARINT_LEN` bytes long.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if `buf` is shorter than the number of bytes required.
|
||||
pub fn encode_varint(mut value: u64, buf: &mut [u8]) -> usize {
|
||||
let mut i = 0;
|
||||
loop {
|
||||
let byte = (value & 0x7F) as u8;
|
||||
value >>= 7;
|
||||
if value == 0 {
|
||||
buf[i] = byte;
|
||||
return i + 1;
|
||||
}
|
||||
buf[i] = byte | 0x80;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode a LEB128 varint from `buf`.
|
||||
///
|
||||
/// Returns `(value, bytes_consumed)` on success.
|
||||
///
|
||||
/// Uses branchless fast paths for the common 1-byte and 2-byte cases,
|
||||
/// falling back to a loop for longer encodings.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `RvfError` if the buffer is too short or the varint exceeds 10
|
||||
/// bytes (which would overflow a u64).
|
||||
pub fn decode_varint(buf: &[u8]) -> Result<(u64, usize), RvfError> {
|
||||
if buf.is_empty() {
|
||||
return Err(RvfError::Code(ErrorCode::TruncatedSegment));
|
||||
}
|
||||
|
||||
// Fast path: 1-byte varint (values 0-127, most common case).
|
||||
let b0 = buf[0];
|
||||
if b0 & 0x80 == 0 {
|
||||
return Ok((b0 as u64, 1));
|
||||
}
|
||||
|
||||
// Fast path: 2-byte varint (values 128-16383).
|
||||
if buf.len() < 2 {
|
||||
return Err(RvfError::Code(ErrorCode::TruncatedSegment));
|
||||
}
|
||||
let b1 = buf[1];
|
||||
if b1 & 0x80 == 0 {
|
||||
let value = ((b0 & 0x7F) as u64) | ((b1 as u64) << 7);
|
||||
return Ok((value, 2));
|
||||
}
|
||||
|
||||
// Slow path: 3+ byte varint.
|
||||
let mut value = ((b0 & 0x7F) as u64) | (((b1 & 0x7F) as u64) << 7);
|
||||
let mut shift: u32 = 14;
|
||||
let limit = buf.len().min(MAX_VARINT_LEN);
|
||||
for (i, &byte) in buf.iter().enumerate().take(limit).skip(2) {
|
||||
value |= ((byte & 0x7F) as u64) << shift;
|
||||
if byte & 0x80 == 0 {
|
||||
return Ok((value, i + 1));
|
||||
}
|
||||
shift += 7;
|
||||
}
|
||||
Err(RvfError::Code(ErrorCode::TruncatedSegment))
|
||||
}
|
||||
|
||||
/// Returns the number of bytes required to encode `value` as a varint.
|
||||
pub fn varint_size(mut value: u64) -> usize {
|
||||
let mut size = 1;
|
||||
while value >= 0x80 {
|
||||
value >>= 7;
|
||||
size += 1;
|
||||
}
|
||||
size
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn round_trip(value: u64) {
|
||||
let mut buf = [0u8; MAX_VARINT_LEN];
|
||||
let written = encode_varint(value, &mut buf);
|
||||
let (decoded, consumed) = decode_varint(&buf[..written]).unwrap();
|
||||
assert_eq!(decoded, value);
|
||||
assert_eq!(consumed, written);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_byte_values() {
|
||||
round_trip(0);
|
||||
round_trip(1);
|
||||
round_trip(127);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_byte_values() {
|
||||
round_trip(128);
|
||||
round_trip(255);
|
||||
round_trip(16383);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_byte_values() {
|
||||
round_trip(16384);
|
||||
round_trip(2_097_151);
|
||||
round_trip(u32::MAX as u64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_u64() {
|
||||
round_trip(u64::MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode_size_matches() {
|
||||
for &val in &[0u64, 1, 127, 128, 16383, 16384, u32::MAX as u64, u64::MAX] {
|
||||
let mut buf = [0u8; MAX_VARINT_LEN];
|
||||
let written = encode_varint(val, &mut buf);
|
||||
assert_eq!(varint_size(val), written);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_truncated_returns_error() {
|
||||
// A single byte with continuation bit set, but no following byte
|
||||
let result = decode_varint(&[0x80]);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_empty_returns_error() {
|
||||
let result = decode_varint(&[]);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
274
crates/rvf/rvf-wire/src/vec_seg_codec.rs
Normal file
274
crates/rvf/rvf-wire/src/vec_seg_codec.rs
Normal file
@@ -0,0 +1,274 @@
|
||||
//! VEC_SEG block codec.
|
||||
//!
|
||||
//! Parses and writes the block directory, columnar vector data, ID map with
|
||||
//! delta-varint encoding, and per-block CRC32C.
|
||||
|
||||
use crate::delta::{decode_delta, encode_delta};
|
||||
use crate::hash::compute_crc32c;
|
||||
|
||||
/// A single entry in the block directory.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct BlockDirEntry {
|
||||
pub block_offset: u32,
|
||||
pub vector_count: u32,
|
||||
pub dim: u16,
|
||||
pub dtype: u8,
|
||||
pub tier: u8,
|
||||
}
|
||||
|
||||
/// Size of the block directory header (block_count: u32).
|
||||
const DIR_HEADER_SIZE: usize = 4;
|
||||
|
||||
/// Size of each directory entry: offset(4) + count(4) + dim(2) + dtype(1) + tier(1) = 12.
|
||||
const DIR_ENTRY_SIZE: usize = 12;
|
||||
|
||||
/// Parsed VEC_SEG block directory.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BlockDirectory {
|
||||
pub entries: Vec<BlockDirEntry>,
|
||||
}
|
||||
|
||||
/// A decoded VEC_SEG block.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct VecBlock {
|
||||
/// Columnar vector data (all dims for dim 0, then dim 1, etc).
|
||||
pub vector_data: Vec<u8>,
|
||||
/// Decoded vector IDs.
|
||||
pub ids: Vec<u64>,
|
||||
/// Dimensions.
|
||||
pub dim: u16,
|
||||
/// Data type.
|
||||
pub dtype: u8,
|
||||
/// Temperature tier.
|
||||
pub tier: u8,
|
||||
}
|
||||
|
||||
/// Parse the block directory from the start of a VEC_SEG payload.
|
||||
///
|
||||
/// Returns the directory and the number of bytes consumed.
|
||||
pub fn read_block_directory(data: &[u8]) -> Result<(BlockDirectory, usize), &'static str> {
|
||||
if data.len() < DIR_HEADER_SIZE {
|
||||
return Err("block directory truncated");
|
||||
}
|
||||
let block_count = u32::from_le_bytes(data[0..4].try_into().unwrap()) as usize;
|
||||
let dir_size = DIR_HEADER_SIZE + block_count * DIR_ENTRY_SIZE;
|
||||
if data.len() < dir_size {
|
||||
return Err("block directory entries truncated");
|
||||
}
|
||||
let mut entries = Vec::with_capacity(block_count);
|
||||
for i in 0..block_count {
|
||||
let base = DIR_HEADER_SIZE + i * DIR_ENTRY_SIZE;
|
||||
entries.push(BlockDirEntry {
|
||||
block_offset: u32::from_le_bytes(data[base..base + 4].try_into().unwrap()),
|
||||
vector_count: u32::from_le_bytes(data[base + 4..base + 8].try_into().unwrap()),
|
||||
dim: u16::from_le_bytes([data[base + 8], data[base + 9]]),
|
||||
dtype: data[base + 10],
|
||||
tier: data[base + 11],
|
||||
});
|
||||
}
|
||||
// Align consumed size to 64 bytes
|
||||
let consumed = (dir_size + 63) & !63;
|
||||
Ok((BlockDirectory { entries }, consumed))
|
||||
}
|
||||
|
||||
/// Write a block directory to a Vec<u8>. Pads to 64-byte alignment.
|
||||
pub fn write_block_directory(entries: &[BlockDirEntry]) -> Vec<u8> {
|
||||
let dir_size = DIR_HEADER_SIZE + entries.len() * DIR_ENTRY_SIZE;
|
||||
let padded = (dir_size + 63) & !63;
|
||||
let mut buf = vec![0u8; padded];
|
||||
buf[0..4].copy_from_slice(&(entries.len() as u32).to_le_bytes());
|
||||
for (i, e) in entries.iter().enumerate() {
|
||||
let base = DIR_HEADER_SIZE + i * DIR_ENTRY_SIZE;
|
||||
buf[base..base + 4].copy_from_slice(&e.block_offset.to_le_bytes());
|
||||
buf[base + 4..base + 8].copy_from_slice(&e.vector_count.to_le_bytes());
|
||||
buf[base + 8..base + 10].copy_from_slice(&e.dim.to_le_bytes());
|
||||
buf[base + 10] = e.dtype;
|
||||
buf[base + 11] = e.tier;
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
/// Size of an element in bytes for a given dtype.
|
||||
fn dtype_element_size(dtype: u8) -> usize {
|
||||
match dtype {
|
||||
0x00 => 4, // f32
|
||||
0x01 => 2, // f16
|
||||
0x02 => 2, // bf16
|
||||
0x03 => 1, // i8
|
||||
0x04 => 1, // u8
|
||||
_ => 1, // fallback
|
||||
}
|
||||
}
|
||||
|
||||
/// Default restart interval for delta-encoded ID maps.
|
||||
const DEFAULT_RESTART_INTERVAL: u32 = 128;
|
||||
|
||||
/// Write a single VEC_SEG block (columnar vectors + ID map + CRC32C).
|
||||
/// Returns the serialized block bytes, padded to 64-byte alignment.
|
||||
pub fn write_vec_block(block: &VecBlock) -> Vec<u8> {
|
||||
let mut payload = Vec::new();
|
||||
|
||||
// Columnar vector data
|
||||
payload.extend_from_slice(&block.vector_data);
|
||||
|
||||
// ID map header
|
||||
let encoding: u8 = 1; // delta-varint
|
||||
let restart_interval: u16 = DEFAULT_RESTART_INTERVAL as u16;
|
||||
let id_count: u32 = block.ids.len() as u32;
|
||||
payload.push(encoding);
|
||||
payload.extend_from_slice(&restart_interval.to_le_bytes());
|
||||
payload.extend_from_slice(&id_count.to_le_bytes());
|
||||
|
||||
// For delta encoding, we store restart offsets (simplified: omit for now)
|
||||
// and then the encoded IDs.
|
||||
let _restart_count = if block.ids.is_empty() {
|
||||
0u32
|
||||
} else {
|
||||
((block.ids.len() as u32 - 1) / DEFAULT_RESTART_INTERVAL) + 1
|
||||
};
|
||||
let mut id_buf = Vec::new();
|
||||
encode_delta(&block.ids, DEFAULT_RESTART_INTERVAL, &mut id_buf);
|
||||
payload.extend_from_slice(&id_buf);
|
||||
|
||||
// Block CRC32C
|
||||
let crc = compute_crc32c(&payload);
|
||||
payload.extend_from_slice(&crc.to_le_bytes());
|
||||
|
||||
// Pad to 64-byte alignment
|
||||
let padded_len = (payload.len() + 63) & !63;
|
||||
payload.resize(padded_len, 0);
|
||||
payload
|
||||
}
|
||||
|
||||
/// Read a single VEC_SEG block at the given offset in the payload.
|
||||
///
|
||||
/// `entry` provides the block metadata from the directory.
|
||||
/// `payload` is the full VEC_SEG payload.
|
||||
pub fn read_vec_block(payload: &[u8], entry: &BlockDirEntry) -> Result<VecBlock, &'static str> {
|
||||
let offset = entry.block_offset as usize;
|
||||
if offset >= payload.len() {
|
||||
return Err("block offset beyond payload");
|
||||
}
|
||||
let block_data = &payload[offset..];
|
||||
|
||||
let elem_size = dtype_element_size(entry.dtype);
|
||||
let vector_data_len = entry.vector_count as usize * entry.dim as usize * elem_size;
|
||||
if block_data.len() < vector_data_len + 1 + 2 + 4 {
|
||||
return Err("block data truncated");
|
||||
}
|
||||
let vector_data = block_data[..vector_data_len].to_vec();
|
||||
|
||||
let mut pos = vector_data_len;
|
||||
let encoding = block_data[pos];
|
||||
pos += 1;
|
||||
let restart_interval = u16::from_le_bytes([block_data[pos], block_data[pos + 1]]);
|
||||
pos += 2;
|
||||
let id_count = u32::from_le_bytes(block_data[pos..pos + 4].try_into().unwrap()) as usize;
|
||||
pos += 4;
|
||||
|
||||
let ids = if encoding == 1 {
|
||||
// Delta-varint encoded
|
||||
decode_delta(&block_data[pos..], id_count, restart_interval as u32)
|
||||
} else {
|
||||
// Raw u64 IDs
|
||||
let mut ids = Vec::with_capacity(id_count);
|
||||
for i in 0..id_count {
|
||||
let id_offset = pos + i * 8;
|
||||
if id_offset + 8 > block_data.len() {
|
||||
return Err("raw ID data truncated");
|
||||
}
|
||||
ids.push(u64::from_le_bytes(
|
||||
block_data[id_offset..id_offset + 8].try_into().unwrap(),
|
||||
));
|
||||
}
|
||||
ids
|
||||
};
|
||||
|
||||
Ok(VecBlock {
|
||||
vector_data,
|
||||
ids,
|
||||
dim: entry.dim,
|
||||
dtype: entry.dtype,
|
||||
tier: entry.tier,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn block_directory_round_trip() {
|
||||
let entries = vec![
|
||||
BlockDirEntry {
|
||||
block_offset: 64,
|
||||
vector_count: 100,
|
||||
dim: 128,
|
||||
dtype: 0,
|
||||
tier: 0,
|
||||
},
|
||||
BlockDirEntry {
|
||||
block_offset: 51264,
|
||||
vector_count: 200,
|
||||
dim: 128,
|
||||
dtype: 1,
|
||||
tier: 1,
|
||||
},
|
||||
];
|
||||
let buf = write_block_directory(&entries);
|
||||
assert_eq!(buf.len() % 64, 0);
|
||||
let (dir, _consumed) = read_block_directory(&buf).unwrap();
|
||||
assert_eq!(dir.entries.len(), 2);
|
||||
assert_eq!(dir.entries[0].block_offset, 64);
|
||||
assert_eq!(dir.entries[0].vector_count, 100);
|
||||
assert_eq!(dir.entries[0].dim, 128);
|
||||
assert_eq!(dir.entries[1].dtype, 1);
|
||||
assert_eq!(dir.entries[1].tier, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vec_block_write_read_round_trip() {
|
||||
// Create a block with 4 vectors of dimension 3 (f32)
|
||||
let dim = 3u16;
|
||||
let count = 4u32;
|
||||
// Columnar: all dim_0, then dim_1, then dim_2
|
||||
let mut vector_data = Vec::new();
|
||||
for d in 0..dim {
|
||||
for v in 0..count {
|
||||
let val = (d as f32) * 10.0 + (v as f32);
|
||||
vector_data.extend_from_slice(&val.to_le_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
let ids = vec![10, 20, 30, 40];
|
||||
let block = VecBlock {
|
||||
vector_data: vector_data.clone(),
|
||||
ids: ids.clone(),
|
||||
dim,
|
||||
dtype: 0, // f32
|
||||
tier: 0,
|
||||
};
|
||||
|
||||
let block_bytes = write_vec_block(&block);
|
||||
assert_eq!(block_bytes.len() % 64, 0);
|
||||
|
||||
// Build a payload with a directory entry pointing to offset 0
|
||||
let entry = BlockDirEntry {
|
||||
block_offset: 0,
|
||||
vector_count: count,
|
||||
dim,
|
||||
dtype: 0,
|
||||
tier: 0,
|
||||
};
|
||||
let decoded = read_vec_block(&block_bytes, &entry).unwrap();
|
||||
assert_eq!(decoded.vector_data, vector_data);
|
||||
assert_eq!(decoded.ids, ids);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_directory() {
|
||||
let buf = write_block_directory(&[]);
|
||||
let (dir, _) = read_block_directory(&buf).unwrap();
|
||||
assert!(dir.entries.is_empty());
|
||||
}
|
||||
}
|
||||
166
crates/rvf/rvf-wire/src/writer.rs
Normal file
166
crates/rvf/rvf-wire/src/writer.rs
Normal file
@@ -0,0 +1,166 @@
|
||||
//! Segment writer: serializes a segment header + payload into a byte buffer.
|
||||
//!
|
||||
//! The writer computes the content hash (XXH3-128 by default), sets the
|
||||
//! timestamp, and pads the output to a 64-byte boundary.
|
||||
|
||||
use crate::hash::compute_content_hash;
|
||||
use rvf_types::{
|
||||
SegmentFlags, SegmentHeader, SEGMENT_ALIGNMENT, SEGMENT_HEADER_SIZE, SEGMENT_MAGIC,
|
||||
SEGMENT_VERSION,
|
||||
};
|
||||
|
||||
/// Default checksum algorithm: XXH3-128.
|
||||
const DEFAULT_CHECKSUM_ALGO: u8 = 1;
|
||||
|
||||
/// Calculate the total padded size of a segment (header + payload + padding).
|
||||
///
|
||||
/// The result is always a multiple of `SEGMENT_ALIGNMENT` (64 bytes).
|
||||
pub fn calculate_padded_size(header_size: usize, payload_size: usize) -> usize {
|
||||
let raw = header_size + payload_size;
|
||||
(raw + SEGMENT_ALIGNMENT - 1) & !(SEGMENT_ALIGNMENT - 1)
|
||||
}
|
||||
|
||||
/// Serialize a complete segment: 64-byte header + payload + zero-padding to
|
||||
/// the next 64-byte boundary.
|
||||
///
|
||||
/// The content hash is computed over the raw payload using XXH3-128 (algo=1).
|
||||
/// The timestamp is set to 0 (callers should overwrite if needed).
|
||||
pub fn write_segment(
|
||||
seg_type: u8,
|
||||
payload: &[u8],
|
||||
flags: SegmentFlags,
|
||||
segment_id: u64,
|
||||
) -> Vec<u8> {
|
||||
write_segment_with_algo(seg_type, payload, flags, segment_id, DEFAULT_CHECKSUM_ALGO)
|
||||
}
|
||||
|
||||
/// Like `write_segment`, but allows specifying the checksum algorithm.
|
||||
pub fn write_segment_with_algo(
|
||||
seg_type: u8,
|
||||
payload: &[u8],
|
||||
flags: SegmentFlags,
|
||||
segment_id: u64,
|
||||
checksum_algo: u8,
|
||||
) -> Vec<u8> {
|
||||
let content_hash = compute_content_hash(checksum_algo, payload);
|
||||
let total_size = calculate_padded_size(SEGMENT_HEADER_SIZE, payload.len());
|
||||
let padding = total_size - SEGMENT_HEADER_SIZE - payload.len();
|
||||
|
||||
let header = SegmentHeader {
|
||||
magic: SEGMENT_MAGIC,
|
||||
version: SEGMENT_VERSION,
|
||||
seg_type,
|
||||
flags: flags.bits(),
|
||||
segment_id,
|
||||
payload_length: payload.len() as u64,
|
||||
timestamp_ns: 0,
|
||||
checksum_algo,
|
||||
compression: 0,
|
||||
reserved_0: 0,
|
||||
reserved_1: 0,
|
||||
content_hash,
|
||||
uncompressed_len: 0,
|
||||
alignment_pad: padding as u32,
|
||||
};
|
||||
|
||||
let mut buf = Vec::with_capacity(total_size);
|
||||
// Serialize header fields in little-endian order
|
||||
buf.extend_from_slice(&header.magic.to_le_bytes());
|
||||
buf.push(header.version);
|
||||
buf.push(header.seg_type);
|
||||
buf.extend_from_slice(&header.flags.to_le_bytes());
|
||||
buf.extend_from_slice(&header.segment_id.to_le_bytes());
|
||||
buf.extend_from_slice(&header.payload_length.to_le_bytes());
|
||||
buf.extend_from_slice(&header.timestamp_ns.to_le_bytes());
|
||||
buf.push(header.checksum_algo);
|
||||
buf.push(header.compression);
|
||||
buf.extend_from_slice(&header.reserved_0.to_le_bytes());
|
||||
buf.extend_from_slice(&header.reserved_1.to_le_bytes());
|
||||
buf.extend_from_slice(&header.content_hash);
|
||||
buf.extend_from_slice(&header.uncompressed_len.to_le_bytes());
|
||||
buf.extend_from_slice(&header.alignment_pad.to_le_bytes());
|
||||
|
||||
debug_assert_eq!(buf.len(), SEGMENT_HEADER_SIZE);
|
||||
|
||||
// Payload
|
||||
buf.extend_from_slice(payload);
|
||||
|
||||
// Zero-padding to 64-byte alignment
|
||||
buf.resize(total_size, 0);
|
||||
|
||||
buf
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rvf_types::SegmentType;
|
||||
|
||||
#[test]
|
||||
fn output_is_64_byte_aligned() {
|
||||
for payload_size in [0, 1, 10, 63, 64, 65, 127, 128, 1000] {
|
||||
let payload = vec![0xABu8; payload_size];
|
||||
let seg = write_segment(SegmentType::Vec as u8, &payload, SegmentFlags::empty(), 0);
|
||||
assert_eq!(
|
||||
seg.len() % SEGMENT_ALIGNMENT,
|
||||
0,
|
||||
"not aligned for payload_size={payload_size}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_magic_and_version() {
|
||||
let seg = write_segment(SegmentType::Vec as u8, b"test", SegmentFlags::empty(), 1);
|
||||
let magic = u32::from_le_bytes([seg[0], seg[1], seg[2], seg[3]]);
|
||||
assert_eq!(magic, SEGMENT_MAGIC);
|
||||
assert_eq!(seg[4], SEGMENT_VERSION);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn segment_id_is_stored() {
|
||||
let seg = write_segment(
|
||||
SegmentType::Index as u8,
|
||||
b"idx",
|
||||
SegmentFlags::empty(),
|
||||
12345,
|
||||
);
|
||||
let id = u64::from_le_bytes(seg[0x08..0x10].try_into().unwrap());
|
||||
assert_eq!(id, 12345);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flags_are_stored() {
|
||||
let flags = SegmentFlags::empty()
|
||||
.with(SegmentFlags::COMPRESSED)
|
||||
.with(SegmentFlags::SEALED);
|
||||
let seg = write_segment(SegmentType::Vec as u8, b"data", flags, 0);
|
||||
let stored_flags = u16::from_le_bytes([seg[6], seg[7]]);
|
||||
assert!(stored_flags & SegmentFlags::COMPRESSED != 0);
|
||||
assert!(stored_flags & SegmentFlags::SEALED != 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payload_length_matches() {
|
||||
let payload = b"hello world!";
|
||||
let seg = write_segment(SegmentType::Vec as u8, payload, SegmentFlags::empty(), 0);
|
||||
let len = u64::from_le_bytes(seg[0x10..0x18].try_into().unwrap());
|
||||
assert_eq!(len, payload.len() as u64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calculate_padded_size_examples() {
|
||||
assert_eq!(calculate_padded_size(64, 0), 64);
|
||||
assert_eq!(calculate_padded_size(64, 1), 128);
|
||||
assert_eq!(calculate_padded_size(64, 64), 128);
|
||||
assert_eq!(calculate_padded_size(64, 65), 192);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_payload() {
|
||||
let seg = write_segment(SegmentType::Meta as u8, &[], SegmentFlags::empty(), 0);
|
||||
assert_eq!(seg.len(), 64);
|
||||
let len = u64::from_le_bytes(seg[0x10..0x18].try_into().unwrap());
|
||||
assert_eq!(len, 0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user