use std::time::Instant;
use crate::{
codec::FibCodeV1, scoring::FibScorer, sidecar::FibSidecarIndex, FibQuantError, FibQuantizer,
Result,
};
#[derive(Debug, Clone, PartialEq)]
pub struct IngestReceipt {
pub batch_count: usize,
pub total_bytes: u64,
pub elapsed_micros: u128,
pub failed: usize,
pub error_messages: Vec<String>,
}
pub struct BatchIngestPipeline<Id>
where
Id: Clone + Eq + std::fmt::Debug,
{
quantizer: FibQuantizer,
index: FibSidecarIndex<Id>,
batch_size: usize,
total_encoded: usize,
total_bytes: u64,
failed: usize,
}
impl<Id> BatchIngestPipeline<Id>
where
Id: Clone + Eq + std::fmt::Debug,
{
pub fn new(quantizer: FibQuantizer, batch_size: usize) -> Result<Self> {
if batch_size == 0 {
return Err(FibQuantError::CorruptPayload(
"batch_size must be > 0".into(),
));
}
let scorer = FibScorer::new(quantizer.clone())?;
let index = FibSidecarIndex::new(scorer);
Ok(Self {
quantizer,
index,
batch_size,
total_encoded: 0,
total_bytes: 0,
failed: 0,
})
}
pub fn ingest_batch(&mut self, items: &[(Id, &[f32])]) -> Result<IngestReceipt> {
let started = Instant::now();
if items.is_empty() {
return Ok(IngestReceipt {
batch_count: 0,
total_bytes: 0,
elapsed_micros: 0,
failed: 0,
error_messages: Vec::new(),
});
}
let vectors: Vec<&[f32]> = items.iter().map(|(_, v)| *v).collect();
let codes = match self.quantizer.encode_batch(&vectors) {
Ok(codes) => codes,
Err(e) => {
let msg = format!("encode_batch failed: {e}");
self.failed += items.len();
return Ok(IngestReceipt {
batch_count: 0,
total_bytes: 0,
elapsed_micros: started.elapsed().as_micros(),
failed: items.len(),
error_messages: vec![msg],
});
}
};
let entries: Vec<(Id, FibCodeV1)> =
items.iter().map(|(id, _)| id.clone()).zip(codes).collect();
let bytes: u64 = entries
.iter()
.map(|(_, code)| code.compact_size() as u64)
.sum();
let count = entries.len();
self.index.add_batch(entries);
self.total_encoded += count;
self.total_bytes += bytes;
let elapsed = started.elapsed().as_micros();
Ok(IngestReceipt {
batch_count: count,
total_bytes: bytes,
elapsed_micros: elapsed,
failed: 0,
error_messages: Vec::new(),
})
}
pub fn ingest_from_iter<I>(&mut self, iter: I) -> Result<Vec<IngestReceipt>>
where
I: Iterator<Item = (Id, Vec<f32>)>,
{
let mut receipts = Vec::new();
let mut chunk: Vec<(Id, Vec<f32>)> = Vec::with_capacity(self.batch_size);
for item in iter {
chunk.push(item);
if chunk.len() >= self.batch_size {
let refs: Vec<(Id, &[f32])> = chunk
.iter()
.map(|(id, v)| (id.clone(), v.as_slice()))
.collect();
receipts.push(self.ingest_batch(&refs)?);
chunk.clear();
}
}
if !chunk.is_empty() {
let refs: Vec<(Id, &[f32])> = chunk
.iter()
.map(|(id, v)| (id.clone(), v.as_slice()))
.collect();
receipts.push(self.ingest_batch(&refs)?);
}
Ok(receipts)
}
pub fn finish(self) -> FibSidecarIndex<Id> {
self.index
}
pub fn total_encoded(&self) -> usize {
self.total_encoded
}
pub fn total_bytes(&self) -> u64 {
self.total_bytes
}
pub fn failed(&self) -> usize {
self.failed
}
pub fn batch_size(&self) -> usize {
self.batch_size
}
pub fn len(&self) -> usize {
self.index.len()
}
pub fn is_empty(&self) -> bool {
self.index.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::profile::FibQuantProfileV1;
fn build_test_quantizer() -> FibQuantizer {
let mut profile = FibQuantProfileV1::paper_default(8, 2, 8, 7).unwrap();
profile.training_samples = 128;
profile.lloyd_restarts = 1;
profile.lloyd_iterations = 2;
FibQuantizer::new(profile).unwrap()
}
fn make_vectors(d: usize, count: usize) -> Vec<Vec<f32>> {
(0..count)
.map(|seed| {
(0..d)
.map(|i| (seed as f32 * 0.1 + i as f32 * 0.05 - 0.3).sin())
.collect()
})
.collect()
}
#[test]
fn ingest_100_vectors_in_batches_of_32() -> Result<()> {
let quantizer = build_test_quantizer();
let d = quantizer.profile().ambient_dim as usize;
let mut pipeline = BatchIngestPipeline::new(quantizer, 32)?;
let vectors = make_vectors(d, 100);
let items: Vec<(u32, Vec<f32>)> = vectors
.iter()
.enumerate()
.map(|(i, v)| (i as u32, v.clone()))
.collect();
let receipts = pipeline.ingest_from_iter(items.into_iter())?;
assert_eq!(receipts.len(), 4, "should produce 4 batch receipts");
assert_eq!(receipts[0].batch_count, 32);
assert_eq!(receipts[1].batch_count, 32);
assert_eq!(receipts[2].batch_count, 32);
assert_eq!(receipts[3].batch_count, 4, "last batch should have 4 items");
let total_count: usize = receipts.iter().map(|r| r.batch_count).sum();
assert_eq!(total_count, 100);
let index = pipeline.finish();
assert_eq!(index.len(), 100, "index should have 100 entries");
assert!(!index.is_empty());
Ok(())
}
#[test]
fn search_works_after_ingest() -> Result<()> {
let quantizer = build_test_quantizer();
let d = quantizer.profile().ambient_dim as usize;
let mut pipeline = BatchIngestPipeline::new(quantizer, 32)?;
let vectors = make_vectors(d, 100);
let items: Vec<(u32, Vec<f32>)> = vectors
.iter()
.enumerate()
.map(|(i, v)| (i as u32, v.clone()))
.collect();
pipeline.ingest_from_iter(items.into_iter())?;
let index = pipeline.finish();
assert_eq!(index.len(), 100);
let query = &vectors[0];
let results = index.search(query, 5, 1)?;
assert_eq!(results.len(), 5, "search should return top_k=5 candidates");
for (i, r) in results.iter().enumerate() {
assert_eq!(r.rank, i, "rank should be sequential from 0");
}
for w in results.windows(2) {
assert!(
w[0].approximate_score >= w[1].approximate_score,
"results should be sorted descending by score"
);
}
let (results2, receipt) = index.search_with_receipt(query, 3, 2)?;
assert_eq!(results2.len(), 6, "top_k=3 oversample=2 should give 6");
assert_eq!(receipt.indexed_count, 100);
assert_eq!(receipt.top_k, 3);
assert_eq!(receipt.oversample, 2);
Ok(())
}
#[test]
fn receipt_fields_are_correct() -> Result<()> {
let quantizer = build_test_quantizer();
let d = quantizer.profile().ambient_dim as usize;
let mut pipeline = BatchIngestPipeline::new(quantizer, 1024)?;
let vectors = make_vectors(d, 50);
let refs: Vec<(u32, &[f32])> = vectors
.iter()
.enumerate()
.map(|(i, v)| (i as u32, v.as_slice()))
.collect();
let receipt = pipeline.ingest_batch(&refs)?;
assert_eq!(
receipt.batch_count, 50,
"batch_count should match input size"
);
assert_eq!(receipt.failed, 0, "no failures expected for valid vectors");
assert!(
receipt.total_bytes > 0,
"total_bytes should be positive for 50 encoded codes"
);
assert!(
receipt.elapsed_micros > 0 || receipt.batch_count == 0,
"elapsed_micros should be positive for non-empty batch"
);
assert!(
receipt.error_messages.is_empty(),
"no error messages expected for valid input"
);
Ok(())
}
#[test]
fn empty_batch_returns_zero_count_receipt() -> Result<()> {
let quantizer = build_test_quantizer();
let mut pipeline = BatchIngestPipeline::new(quantizer, 1024)?;
let empty: Vec<(u32, &[f32])> = vec![];
let receipt = pipeline.ingest_batch(&empty)?;
assert_eq!(receipt.batch_count, 0);
assert_eq!(receipt.total_bytes, 0);
assert_eq!(receipt.failed, 0);
assert!(receipt.error_messages.is_empty());
Ok(())
}
#[test]
fn finish_returns_index_with_all_entries() -> Result<()> {
let quantizer = build_test_quantizer();
let d = quantizer.profile().ambient_dim as usize;
let mut pipeline = BatchIngestPipeline::new(quantizer, 16)?;
let vectors = make_vectors(d, 40);
let items: Vec<(u32, Vec<f32>)> = vectors
.iter()
.enumerate()
.map(|(i, v)| (i as u32, v.clone()))
.collect();
pipeline.ingest_from_iter(items.into_iter())?;
assert_eq!(pipeline.total_encoded(), 40);
assert_eq!(pipeline.failed(), 0);
assert!(pipeline.total_bytes() > 0);
assert_eq!(pipeline.len(), 40);
let index = pipeline.finish();
assert_eq!(index.len(), 40);
Ok(())
}
}