fib_quant/batch_ingest.rs
1//! High-throughput batch ingest pipeline for encoding and inserting
2//! large vector corpora (100K+) into a [`FibSidecarIndex`].
3//!
4//! The pipeline wraps a [`FibQuantizer`] and [`FibSidecarIndex`], encoding
5//! vectors in batches via [`FibQuantizer::encode_batch`] (which uses Rayon
6//! parallelism when the `parallel` feature is enabled) and inserting the
7//! resulting [`FibCodeV1`] artifacts into the sidecar index. Each batch
8//! produces an [`IngestReceipt`] with timing, byte counts, and any errors.
9//!
10//! ## Throughput
11//!
12//! The pipeline is designed for 100K+ vector corpora. The encoding step
13//! is dominated by [`FibQuantizer::encode_batch`], which dispatches the
14//! per-vector codebook lookup across Rayon worker threads when the batch
15//! is large enough (≥ 16 vectors). The index insertion step
16//! ([`FibSidecarIndex::add_batch`]) is a simple `Vec::push` loop with no
17//! per-entry allocation beyond the `(Id, FibCodeV1)` tuple.
18//!
19//! ## Example
20//!
21//! # use fib_quant::{BatchIngestPipeline, FibQuantProfileV1, FibQuantizer};
22//! # fn main() -> fib_quant::Result<()> {
23//! # let mut profile = FibQuantProfileV1::paper_default(8, 2, 8, 7)?;
24//! # profile.training_samples = 128;
25//! # profile.lloyd_restarts = 1;
26//! # profile.lloyd_iterations = 2;
27//! # let quantizer = FibQuantizer::new(profile)?;
28//! # let mut pipeline = BatchIngestPipeline::new(quantizer, 32)?;
29//! # let items: Vec<(u32, Vec<f32>)> = (0..100)
30//! # .map(|i| (i as u32, vec![0.1 * i as f32 + 0.01; 8]))
31//! # .collect();
32//! # let receipts = pipeline.ingest_from_iter(items.into_iter())?;
33//! # let total: usize = receipts.iter().map(|r| r.batch_count).sum();
34//! # assert_eq!(total, 100);
35//! # let index = pipeline.finish();
36//! # assert_eq!(index.len(), 100);
37//! # Ok(())
38//! # }
39//! ```
40
41use std::time::Instant;
42
43use crate::{
44 codec::FibCodeV1, scoring::FibScorer, sidecar::FibSidecarIndex, FibQuantError, FibQuantizer,
45 Result,
46};
47
48/// Receipt documenting the outcome of a single batch ingest operation.
49///
50/// Produced by [`BatchIngestPipeline::ingest_batch`] for each batch of
51/// vectors processed. The receipt captures the count of successfully
52/// encoded/inserted vectors, the total encoded byte count (using
53/// [`FibCodeV1::compact_size`]), the elapsed time, and any failures.
54///
55/// For multi-batch ingestion via
56/// [`ingest_from_iter`](BatchIngestPipeline::ingest_from_iter), one
57/// `IngestReceipt` is returned per chunk.
58#[derive(Debug, Clone, PartialEq)]
59pub struct IngestReceipt {
60 /// Number of vectors successfully encoded and inserted in this batch.
61 pub batch_count: usize,
62 /// Total encoded bytes (sum of [`FibCodeV1::compact_size`] for all
63 /// successfully encoded codes in this batch).
64 pub total_bytes: u64,
65 /// Elapsed time for this batch in microseconds (encode + insert).
66 pub elapsed_micros: u128,
67 /// Number of vectors that failed to encode or insert.
68 pub failed: usize,
69 /// Error messages for any failures (empty if all succeeded).
70 pub error_messages: Vec<String>,
71}
72
73/// High-throughput batch ingest pipeline for encoding and inserting
74/// large vector corpora into a [`FibSidecarIndex`].
75///
76/// The pipeline owns a [`FibQuantizer`] for encoding and a
77/// [`FibSidecarIndex`] for storage. Vectors are processed in configurable
78/// batch sizes via [`ingest_batch`](Self::ingest_batch) or
79/// [`ingest_from_iter`](Self::ingest_from_iter), with each batch producing
80/// an [`IngestReceipt`] for progress tracking.
81///
82/// ## Parallelism
83///
84/// [`FibQuantizer::encode_batch`] uses Rayon parallelism internally when
85/// the `parallel` feature is enabled (default) and the batch is large
86/// enough to amortize dispatch overhead (≥ 16 vectors). The pipeline
87/// itself does not add additional parallelism — it relies on the codec's
88/// internal parallelism for the compute-heavy encoding step.
89///
90/// ## Generics
91///
92/// `Id` must be `Clone + Eq + Debug`, matching the bound on
93/// [`FibSidecarIndex`]. Common choices are `u64`, `String`, or a newtype
94/// key.
95pub struct BatchIngestPipeline<Id>
96where
97 Id: Clone + Eq + std::fmt::Debug,
98{
99 quantizer: FibQuantizer,
100 index: FibSidecarIndex<Id>,
101 batch_size: usize,
102 total_encoded: usize,
103 total_bytes: u64,
104 failed: usize,
105}
106
107impl<Id> BatchIngestPipeline<Id>
108where
109 Id: Clone + Eq + std::fmt::Debug,
110{
111 /// Create a new batch ingest pipeline.
112 ///
113 /// Builds a [`FibScorer`] from the quantizer (cloning it so the
114 /// pipeline retains its own copy for encoding) and creates an empty
115 /// [`FibSidecarIndex`].
116 ///
117 /// `batch_size` is the default chunk size used by
118 /// [`ingest_from_iter`](Self::ingest_from_iter). It must be > 0.
119 ///
120 /// # Errors
121 ///
122 /// Returns [`FibQuantError::CorruptPayload`] if `batch_size` is 0.
123 pub fn new(quantizer: FibQuantizer, batch_size: usize) -> Result<Self> {
124 if batch_size == 0 {
125 return Err(FibQuantError::CorruptPayload(
126 "batch_size must be > 0".into(),
127 ));
128 }
129 let scorer = FibScorer::new(quantizer.clone())?;
130 let index = FibSidecarIndex::new(scorer);
131 Ok(Self {
132 quantizer,
133 index,
134 batch_size,
135 total_encoded: 0,
136 total_bytes: 0,
137 failed: 0,
138 })
139 }
140
141 /// Ingest a batch of `(Id, vector)` pairs.
142 ///
143 /// Extracts all f32 vector slices, calls [`FibQuantizer::encode_batch`]
144 /// for parallel encoding (Rayon when the `parallel` feature is enabled
145 /// and the batch is ≥ 16 vectors), then adds all encoded codes to the
146 /// sidecar index via [`FibSidecarIndex::add_batch`]. Returns an
147 /// [`IngestReceipt`] with the count, bytes, timing, and any errors.
148 ///
149 /// If encoding fails for the entire batch (e.g., dimension mismatch or
150 /// a zero-norm vector), the receipt will have `failed = items.len()`
151 /// and the error will be recorded in `error_messages`. The index is
152 /// not modified in this case.
153 ///
154 /// # Empty batches
155 ///
156 /// An empty `items` slice returns a zero-count receipt immediately
157 /// without touching the quantizer or index.
158 pub fn ingest_batch(&mut self, items: &[(Id, &[f32])]) -> Result<IngestReceipt> {
159 let started = Instant::now();
160
161 if items.is_empty() {
162 return Ok(IngestReceipt {
163 batch_count: 0,
164 total_bytes: 0,
165 elapsed_micros: 0,
166 failed: 0,
167 error_messages: Vec::new(),
168 });
169 }
170
171 // Extract vector slices for encode_batch
172 let vectors: Vec<&[f32]> = items.iter().map(|(_, v)| *v).collect();
173
174 // Encode all vectors (uses Rayon parallelism internally)
175 let codes = match self.quantizer.encode_batch(&vectors) {
176 Ok(codes) => codes,
177 Err(e) => {
178 let msg = format!("encode_batch failed: {e}");
179 self.failed += items.len();
180 return Ok(IngestReceipt {
181 batch_count: 0,
182 total_bytes: 0,
183 elapsed_micros: started.elapsed().as_micros(),
184 failed: items.len(),
185 error_messages: vec![msg],
186 });
187 }
188 };
189
190 // Pair IDs with codes and add to index
191 let entries: Vec<(Id, FibCodeV1)> =
192 items.iter().map(|(id, _)| id.clone()).zip(codes).collect();
193
194 // Track bytes before moving entries into the index
195 let bytes: u64 = entries
196 .iter()
197 .map(|(_, code)| code.compact_size() as u64)
198 .sum();
199 let count = entries.len();
200
201 self.index.add_batch(entries);
202 self.total_encoded += count;
203 self.total_bytes += bytes;
204
205 let elapsed = started.elapsed().as_micros();
206
207 Ok(IngestReceipt {
208 batch_count: count,
209 total_bytes: bytes,
210 elapsed_micros: elapsed,
211 failed: 0,
212 error_messages: Vec::new(),
213 })
214 }
215
216 /// Ingest vectors from an iterator, chunking into `batch_size` pieces.
217 ///
218 /// Each chunk is passed to [`ingest_batch`](Self::ingest_batch), and
219 /// all receipts are collected and returned. This is the primary entry
220 /// point for streaming large corpora (100K+ vectors) through the
221 /// pipeline.
222 ///
223 /// The iterator is consumed lazily — only one chunk is held in memory
224 /// at a time (plus the receipts vector). This keeps memory usage
225 /// bounded regardless of the total corpus size.
226 pub fn ingest_from_iter<I>(&mut self, iter: I) -> Result<Vec<IngestReceipt>>
227 where
228 I: Iterator<Item = (Id, Vec<f32>)>,
229 {
230 let mut receipts = Vec::new();
231 let mut chunk: Vec<(Id, Vec<f32>)> = Vec::with_capacity(self.batch_size);
232
233 for item in iter {
234 chunk.push(item);
235 if chunk.len() >= self.batch_size {
236 // Convert owned Vecs to slices for ingest_batch
237 let refs: Vec<(Id, &[f32])> = chunk
238 .iter()
239 .map(|(id, v)| (id.clone(), v.as_slice()))
240 .collect();
241 receipts.push(self.ingest_batch(&refs)?);
242 chunk.clear();
243 }
244 }
245
246 // Process remaining items in the final partial chunk
247 if !chunk.is_empty() {
248 let refs: Vec<(Id, &[f32])> = chunk
249 .iter()
250 .map(|(id, v)| (id.clone(), v.as_slice()))
251 .collect();
252 receipts.push(self.ingest_batch(&refs)?);
253 }
254
255 Ok(receipts)
256 }
257
258 /// Consume the pipeline and return the completed [`FibSidecarIndex`].
259 ///
260 /// The index contains all successfully encoded and inserted entries.
261 /// After calling `finish`, the pipeline is consumed and can no longer
262 /// be used.
263 pub fn finish(self) -> FibSidecarIndex<Id> {
264 self.index
265 }
266
267 /// Number of vectors successfully encoded and inserted so far.
268 pub fn total_encoded(&self) -> usize {
269 self.total_encoded
270 }
271
272 /// Total encoded bytes accumulated so far.
273 pub fn total_bytes(&self) -> u64 {
274 self.total_bytes
275 }
276
277 /// Number of vectors that failed to encode or insert so far.
278 pub fn failed(&self) -> usize {
279 self.failed
280 }
281
282 /// Configured batch size.
283 pub fn batch_size(&self) -> usize {
284 self.batch_size
285 }
286
287 /// Current number of entries in the underlying index.
288 pub fn len(&self) -> usize {
289 self.index.len()
290 }
291
292 /// Whether the index is empty.
293 pub fn is_empty(&self) -> bool {
294 self.index.is_empty()
295 }
296}
297
298// ======================================================================
299// Tests
300// ======================================================================
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305 use crate::profile::FibQuantProfileV1;
306
307 fn build_test_quantizer() -> FibQuantizer {
308 let mut profile = FibQuantProfileV1::paper_default(8, 2, 8, 7).unwrap();
309 profile.training_samples = 128;
310 profile.lloyd_restarts = 1;
311 profile.lloyd_iterations = 2;
312 FibQuantizer::new(profile).unwrap()
313 }
314
315 fn make_vectors(d: usize, count: usize) -> Vec<Vec<f32>> {
316 (0..count)
317 .map(|seed| {
318 (0..d)
319 .map(|i| (seed as f32 * 0.1 + i as f32 * 0.05 - 0.3).sin())
320 .collect()
321 })
322 .collect()
323 }
324
325 #[test]
326 fn ingest_100_vectors_in_batches_of_32() -> Result<()> {
327 let quantizer = build_test_quantizer();
328 let d = quantizer.profile().ambient_dim as usize;
329 let mut pipeline = BatchIngestPipeline::new(quantizer, 32)?;
330
331 let vectors = make_vectors(d, 100);
332 let items: Vec<(u32, Vec<f32>)> = vectors
333 .iter()
334 .enumerate()
335 .map(|(i, v)| (i as u32, v.clone()))
336 .collect();
337
338 let receipts = pipeline.ingest_from_iter(items.into_iter())?;
339
340 // 100 vectors / 32 per batch = 4 batches (32, 32, 32, 4)
341 assert_eq!(receipts.len(), 4, "should produce 4 batch receipts");
342 assert_eq!(receipts[0].batch_count, 32);
343 assert_eq!(receipts[1].batch_count, 32);
344 assert_eq!(receipts[2].batch_count, 32);
345 assert_eq!(receipts[3].batch_count, 4, "last batch should have 4 items");
346
347 let total_count: usize = receipts.iter().map(|r| r.batch_count).sum();
348 assert_eq!(total_count, 100);
349
350 let index = pipeline.finish();
351 assert_eq!(index.len(), 100, "index should have 100 entries");
352 assert!(!index.is_empty());
353
354 Ok(())
355 }
356
357 #[test]
358 fn search_works_after_ingest() -> Result<()> {
359 let quantizer = build_test_quantizer();
360 let d = quantizer.profile().ambient_dim as usize;
361 let mut pipeline = BatchIngestPipeline::new(quantizer, 32)?;
362
363 let vectors = make_vectors(d, 100);
364 let items: Vec<(u32, Vec<f32>)> = vectors
365 .iter()
366 .enumerate()
367 .map(|(i, v)| (i as u32, v.clone()))
368 .collect();
369
370 pipeline.ingest_from_iter(items.into_iter())?;
371
372 let index = pipeline.finish();
373 assert_eq!(index.len(), 100);
374
375 // Verify search returns the correct number of candidates
376 let query = &vectors[0];
377 let results = index.search(query, 5, 1)?;
378 assert_eq!(results.len(), 5, "search should return top_k=5 candidates");
379
380 // Ranks should be sequential from 0
381 for (i, r) in results.iter().enumerate() {
382 assert_eq!(r.rank, i, "rank should be sequential from 0");
383 }
384
385 // Results should be sorted by descending approximate score
386 for w in results.windows(2) {
387 assert!(
388 w[0].approximate_score >= w[1].approximate_score,
389 "results should be sorted descending by score"
390 );
391 }
392
393 // Verify search with receipt also works
394 let (results2, receipt) = index.search_with_receipt(query, 3, 2)?;
395 assert_eq!(results2.len(), 6, "top_k=3 oversample=2 should give 6");
396 assert_eq!(receipt.indexed_count, 100);
397 assert_eq!(receipt.top_k, 3);
398 assert_eq!(receipt.oversample, 2);
399
400 Ok(())
401 }
402
403 #[test]
404 fn receipt_fields_are_correct() -> Result<()> {
405 let quantizer = build_test_quantizer();
406 let d = quantizer.profile().ambient_dim as usize;
407 let mut pipeline = BatchIngestPipeline::new(quantizer, 1024)?;
408
409 let vectors = make_vectors(d, 50);
410 let refs: Vec<(u32, &[f32])> = vectors
411 .iter()
412 .enumerate()
413 .map(|(i, v)| (i as u32, v.as_slice()))
414 .collect();
415
416 let receipt = pipeline.ingest_batch(&refs)?;
417
418 assert_eq!(
419 receipt.batch_count, 50,
420 "batch_count should match input size"
421 );
422 assert_eq!(receipt.failed, 0, "no failures expected for valid vectors");
423 assert!(
424 receipt.total_bytes > 0,
425 "total_bytes should be positive for 50 encoded codes"
426 );
427 assert!(
428 receipt.elapsed_micros > 0 || receipt.batch_count == 0,
429 "elapsed_micros should be positive for non-empty batch"
430 );
431 assert!(
432 receipt.error_messages.is_empty(),
433 "no error messages expected for valid input"
434 );
435
436 Ok(())
437 }
438
439 #[test]
440 fn empty_batch_returns_zero_count_receipt() -> Result<()> {
441 let quantizer = build_test_quantizer();
442 let mut pipeline = BatchIngestPipeline::new(quantizer, 1024)?;
443
444 let empty: Vec<(u32, &[f32])> = vec![];
445 let receipt = pipeline.ingest_batch(&empty)?;
446
447 assert_eq!(receipt.batch_count, 0);
448 assert_eq!(receipt.total_bytes, 0);
449 assert_eq!(receipt.failed, 0);
450 assert!(receipt.error_messages.is_empty());
451
452 Ok(())
453 }
454
455 #[test]
456 fn finish_returns_index_with_all_entries() -> Result<()> {
457 let quantizer = build_test_quantizer();
458 let d = quantizer.profile().ambient_dim as usize;
459 let mut pipeline = BatchIngestPipeline::new(quantizer, 16)?;
460
461 let vectors = make_vectors(d, 40);
462 let items: Vec<(u32, Vec<f32>)> = vectors
463 .iter()
464 .enumerate()
465 .map(|(i, v)| (i as u32, v.clone()))
466 .collect();
467
468 pipeline.ingest_from_iter(items.into_iter())?;
469
470 assert_eq!(pipeline.total_encoded(), 40);
471 assert_eq!(pipeline.failed(), 0);
472 assert!(pipeline.total_bytes() > 0);
473 assert_eq!(pipeline.len(), 40);
474
475 let index = pipeline.finish();
476 assert_eq!(index.len(), 40);
477
478 Ok(())
479 }
480}