Skip to main content

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}