tidaldb/tidal/src/storage/vector/brute/mod.rs
jx12n 69ae6e64a1 fix: M0-M10 remediation — deferred post-filters, WAL hardening, docker build fixes
- Extract shared deferred post-filter (InCollection/MinSignal/MaxSignal/
  NearLocation/SocialGraph) into query/executor/post_filter.rs so RETRIEVE and
  SEARCH apply identical predicates; SEARCH previously ignored four variants
- Harden WAL writer/compaction/dedup paths
- Brute-force vector store fixes + tests
- Governance community ledger updates
- Pin docker builders to bookworm (glibc/libmvec match) and add
  protobuf-compiler for tidal-net's tonic-build
- Add M0-M10 code-review document
2026-06-07 20:47:17 -06:00

356 lines
13 KiB
Rust

//! Brute-force (exact) vector index.
//!
//! [`BruteForceIndex`] performs linear-scan L2 search over all stored vectors.
//! It is the correctness baseline: every other index implementation must return
//! the same top-k results (within quantization tolerance). It is also used for
//! small datasets where the O(n) scan cost is acceptable.
use std::{collections::HashMap, io::Write, path::Path, sync::RwLock};
use super::{
VectorError, VectorId, VectorIndex, VectorIndexConfig, VectorSearchResult, read_f32_le,
read_u32_le, read_u64_le, validate_dimensions,
};
// ---------------------------------------------------------------------------
// Binary format constants
// ---------------------------------------------------------------------------
/// Magic bytes identifying a brute-force vector index file.
const MAGIC: &[u8; 4] = b"BFVI";
/// Current binary format version.
const FORMAT_VERSION: u8 = 0x01;
// ---------------------------------------------------------------------------
// Distance functions
// ---------------------------------------------------------------------------
/// Compute the squared Euclidean (L2) distance between two vectors.
///
/// This avoids the `sqrt` call -- the squared distance preserves ranking order
/// and is sufficient for nearest-neighbor selection.
///
/// # Panics (debug only)
///
/// Debug-asserts that `a` and `b` have the same length.
pub fn l2_distance_sq(a: &[f32], b: &[f32]) -> f32 {
debug_assert_eq!(a.len(), b.len());
a.iter()
.zip(b.iter())
.map(|(x, y)| {
let d = x - y;
d * d
})
.sum()
}
// ---------------------------------------------------------------------------
// BruteForceIndex
// ---------------------------------------------------------------------------
/// Exact nearest-neighbor index using linear scan.
///
/// Stores all vectors in a `HashMap` behind a `RwLock`. Search computes L2
/// squared distance against every stored vector and returns the top-k by
/// ascending distance.
///
/// This implementation has no tombstones -- `delete()` is a true removal.
/// `len()` and `len_live()` always return the same value.
///
/// # Thread safety
///
/// All reads take a shared lock; all writes take an exclusive lock. This is
/// acceptable because brute-force is not a hot-path production index -- it is
/// used for correctness baselines, small datasets, and tests.
#[derive(Debug)]
pub struct BruteForceIndex {
vectors: RwLock<HashMap<VectorId, Vec<f32>>>,
config: VectorIndexConfig,
}
impl BruteForceIndex {
/// Create a new, empty brute-force index with the given configuration.
#[must_use]
pub fn new(config: VectorIndexConfig) -> Self {
Self {
vectors: RwLock::new(HashMap::new()),
config,
}
}
/// Validate that a vector's dimensionality matches the index configuration.
const fn validate_dimensions(&self, vec: &[f32]) -> Result<(), VectorError> {
validate_dimensions(self.config.dimensions, vec.len())
}
}
impl VectorIndex for BruteForceIndex {
fn insert(&self, id: VectorId, embedding: &[f32]) -> Result<(), VectorError> {
self.validate_dimensions(embedding)?;
self.vectors
.write()
.map_err(|e| VectorError::Backend(format!("RwLock poisoned on write: {e}")))?
.insert(id, embedding.to_vec());
Ok(())
}
/// Search for the `k` nearest neighbors by exhaustive linear scan.
///
/// The `ef_search` parameter is accepted for trait compliance but ignored --
/// brute-force search is exact and has no beam width parameter.
fn search(
&self,
query: &[f32],
k: usize,
_ef_search: usize,
) -> Result<Vec<VectorSearchResult>, VectorError> {
self.validate_dimensions(query)?;
let guard = self
.vectors
.read()
.map_err(|e| VectorError::Backend(format!("RwLock poisoned on read: {e}")))?;
let mut results: Vec<VectorSearchResult> = guard
.iter()
.map(|(id, vec)| VectorSearchResult {
id: *id,
distance: l2_distance_sq(query, vec),
})
.collect();
drop(guard);
results.sort_by(|a, b| {
a.distance
.partial_cmp(&b.distance)
.unwrap_or(std::cmp::Ordering::Equal)
});
results.truncate(k);
Ok(results)
}
/// Filtered search: only compute distance for vectors where `filter(id)` is true.
///
/// The `ef_search` parameter is accepted for trait compliance but ignored.
fn filtered_search(
&self,
query: &[f32],
k: usize,
_ef_search: usize,
filter: &dyn Fn(VectorId) -> bool,
) -> Result<Vec<VectorSearchResult>, VectorError> {
self.validate_dimensions(query)?;
let guard = self
.vectors
.read()
.map_err(|e| VectorError::Backend(format!("RwLock poisoned on read: {e}")))?;
let mut results: Vec<VectorSearchResult> = guard
.iter()
.filter(|(id, _)| filter(**id))
.map(|(id, vec)| VectorSearchResult {
id: *id,
distance: l2_distance_sq(query, vec),
})
.collect();
drop(guard);
results.sort_by(|a, b| {
a.distance
.partial_cmp(&b.distance)
.unwrap_or(std::cmp::Ordering::Equal)
});
results.truncate(k);
Ok(results)
}
fn delete(&self, id: VectorId) -> Result<(), VectorError> {
let removed = self
.vectors
.write()
.map_err(|e| VectorError::Backend(format!("RwLock poisoned on write: {e}")))?
.remove(&id);
if removed.is_none() {
return Err(VectorError::NotFound { id });
}
Ok(())
}
/// Reserve additional capacity. This is a no-op for `HashMap`-backed storage --
/// `HashMap` resizes automatically. The method is provided for trait compliance
/// and always succeeds.
fn reserve(&self, _additional: usize) -> Result<(), VectorError> {
Ok(())
}
fn save(&self, path: &Path) -> Result<(), VectorError> {
let guard = self
.vectors
.read()
.map_err(|e| VectorError::Backend(format!("RwLock poisoned on read: {e}")))?;
let mut file = std::fs::File::create(path)?;
// Header: magic + version + dimensions + count
file.write_all(MAGIC)?;
file.write_all(&[FORMAT_VERSION])?;
let dims = self.config.dimensions as u32;
file.write_all(&dims.to_le_bytes())?;
let count = guard.len() as u64;
file.write_all(&count.to_le_bytes())?;
// Per-vector: id + floats
for (id, vec) in &*guard {
file.write_all(&id.to_le_bytes())?;
for &val in vec {
file.write_all(&val.to_le_bytes())?;
}
}
drop(guard);
file.flush().map_err(std::convert::Into::into)
}
fn load(path: &Path, config: &VectorIndexConfig) -> Result<Self, VectorError> {
let data = std::fs::read(path)?;
Self::deserialize(&data, config)
}
/// For brute-force, `view` delegates to `load` -- there is no mmap mode.
/// The entire index is read into memory regardless.
fn view(path: &Path, config: &VectorIndexConfig) -> Result<Self, VectorError> {
Self::load(path, config)
}
fn len(&self) -> usize {
self.vectors.read().map_or(0, |guard| guard.len())
}
fn len_live(&self) -> usize {
// No tombstones in brute-force -- all entries are live.
self.len()
}
}
impl BruteForceIndex {
/// Deserialize from a byte buffer (shared by `load` and `view`).
fn deserialize(data: &[u8], config: &VectorIndexConfig) -> Result<Self, VectorError> {
// Minimum header size: 4 (magic) + 1 (version) + 4 (dims) + 8 (count) = 17
const HEADER_SIZE: usize = 17;
if data.len() < HEADER_SIZE {
return Err(VectorError::CorruptedIndex(
"file too small for header".into(),
));
}
// Validate magic
if &data[..4] != MAGIC {
return Err(VectorError::CorruptedIndex(format!(
"invalid magic: expected {:?}, got {:?}",
MAGIC,
&data[..4]
)));
}
// Validate version
if data[4] != FORMAT_VERSION {
return Err(VectorError::CorruptedIndex(format!(
"unsupported version: expected {FORMAT_VERSION:#04x}, got {:#04x}",
data[4]
)));
}
// Read dimensions
let dims = read_u32_le(data, 5) as usize;
if dims != config.dimensions {
return Err(VectorError::CorruptedIndex(format!(
"dimension mismatch: file has {dims}, config expects {}",
config.dimensions
)));
}
// Read count.
//
// SECURITY: `count` comes straight off disk and is fully
// corruption/attacker-controlled. Every byte of arithmetic below uses
// checked operations and is bounded against the *actual* buffer length
// BEFORE any allocation or loop. Without this, a hostile `count` would
// (a) overflow `count * bytes_per_vector` in release builds, wrapping to
// a tiny `expected_size` that slips past the truncation guard, then
// (b) drive `HashMap::with_capacity(count)` into a multi-exabyte alloc
// (process abort) and an out-of-bounds read loop (panic). A corrupt
// *derived* index is recoverable (rebuildable from the entity store), so
// it must surface as `CorruptedIndex`, never crash the recovery path.
let count = read_u64_le(data, 9) as usize;
// Bytes consumed per stored vector: id (8) + floats (dims * 4).
// Guard `dims` (also from disk-ish via header; already matched against
// config above, but compute defensively) so the multiply cannot wrap.
let float_bytes = dims
.checked_mul(4)
.ok_or_else(|| VectorError::CorruptedIndex(format!("dimensions too large: {dims}")))?;
let bytes_per_vector = 8usize.checked_add(float_bytes).ok_or_else(|| {
VectorError::CorruptedIndex(format!("per-vector size overflow at dims {dims}"))
})?;
debug_assert!(
bytes_per_vector >= 8,
"bytes_per_vector includes the 8-byte id and cannot be zero"
);
// Cap `count` by how many vectors the body could actually contain. This
// makes the subsequent `with_capacity` and read loop safe regardless of
// the on-disk value: a damaged header can never request more entries
// than the buffer can hold.
let body_len = data.len() - HEADER_SIZE; // safe: data.len() >= HEADER_SIZE checked above
let max_count = body_len / bytes_per_vector; // bytes_per_vector >= 8, no div-by-zero
if count > max_count {
return Err(VectorError::CorruptedIndex(format!(
"implausible vector count: header claims {count}, but the {body_len}-byte \
body holds at most {max_count} vectors of {bytes_per_vector} bytes each"
)));
}
// Validate the exact declared size (now provably non-overflowing because
// `count <= max_count` and `max_count * bytes_per_vector <= body_len`).
let expected_size = count
.checked_mul(bytes_per_vector)
.and_then(|body| body.checked_add(HEADER_SIZE))
.ok_or_else(|| {
VectorError::CorruptedIndex(format!(
"index size overflow: count {count} * {bytes_per_vector} + {HEADER_SIZE}"
))
})?;
if data.len() < expected_size {
return Err(VectorError::CorruptedIndex(format!(
"file truncated: expected at least {expected_size} bytes, got {}",
data.len()
)));
}
// Parse vectors. `count` is now bounded by the buffer, so `with_capacity`
// cannot over-allocate and the read loop cannot index out of bounds.
let mut vectors = HashMap::with_capacity(count);
let mut offset = HEADER_SIZE;
for _ in 0..count {
let id = read_u64_le(data, offset);
offset += 8;
let mut vec = Vec::with_capacity(dims);
for _ in 0..dims {
vec.push(read_f32_le(data, offset));
offset += 4;
}
vectors.insert(id, vec);
}
Ok(Self {
vectors: RwLock::new(vectors),
config: config.clone(),
})
}
}
#[cfg(test)]
mod tests;