Skip to main content

next_plaid/
update.rs

1//! Index update functionality for adding new documents.
2//!
3//! This module provides functions to incrementally update an existing PLAID index
4//! with new documents, matching fast-plaid's behavior:
5//! - Buffer mechanism for small updates
6//! - Centroid expansion for outliers
7//! - Cluster threshold updates
8
9use std::cell::RefCell;
10use std::collections::HashMap;
11use std::fs;
12use std::fs::File;
13use std::io::{BufReader, BufWriter, Write};
14use std::path::Path;
15
16use serde::{Deserialize, Serialize};
17
18use ndarray::{s, Array1, Array2, Axis};
19use rayon::prelude::*;
20
21use crate::codec::ResidualCodec;
22use crate::error::Error;
23use crate::error::Result;
24use crate::index::Metadata;
25use crate::kmeans::compute_kmeans;
26use crate::kmeans::ComputeKmeansConfig;
27use crate::utils::atomic_write_file;
28use crate::utils::quantile;
29
30/// Default batch size for processing documents (matches fast-plaid).
31const DEFAULT_BATCH_SIZE: usize = 50_000;
32
33/// Thread-local progress callback invoked with `(stage, message)` during an update.
34type ProgressCallback = Box<dyn Fn(&str, &str)>;
35
36thread_local! {
37    static UPDATE_PROGRESS: RefCell<Option<ProgressCallback>> = const { RefCell::new(None) };
38}
39
40struct ProgressGuard;
41
42impl Drop for ProgressGuard {
43    fn drop(&mut self) {
44        UPDATE_PROGRESS.with(|slot| {
45            *slot.borrow_mut() = None;
46        });
47    }
48}
49
50/// Run an update operation with a thread-local progress callback.
51///
52/// The callback is intentionally thread-local because update work runs inside one blocking
53/// worker thread; this avoids plumbing API-specific state through the public update config.
54pub fn with_update_progress<F, R>(callback: F, operation: impl FnOnce() -> R) -> R
55where
56    F: Fn(&str, &str) + 'static,
57{
58    UPDATE_PROGRESS.with(|slot| {
59        *slot.borrow_mut() = Some(Box::new(callback));
60    });
61    let _guard = ProgressGuard;
62    operation()
63}
64
65fn emit_update_progress(stage: &str, message: &str) {
66    UPDATE_PROGRESS.with(|slot| {
67        if let Some(callback) = slot.borrow().as_ref() {
68            callback(stage, message);
69        }
70    });
71}
72
73/// Configuration for index updates.
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct UpdateConfig {
76    /// Batch size for processing documents (default: 50,000)
77    pub batch_size: usize,
78    /// Number of K-means iterations for centroid expansion (default: 4)
79    pub kmeans_niters: usize,
80    /// Max points per centroid for K-means (default: 256)
81    pub max_points_per_centroid: usize,
82    /// Number of samples for K-means (default: auto-calculated)
83    pub n_samples_kmeans: Option<usize>,
84    /// Random seed (default: 42)
85    pub seed: u64,
86    /// If index has fewer docs than this, rebuild from scratch (default: 999)
87    pub start_from_scratch: usize,
88    /// Buffer size before triggering centroid expansion (default: 100)
89    pub buffer_size: usize,
90    /// Force CPU execution for K-means even when CUDA feature is enabled.
91    #[serde(default)]
92    pub force_cpu: bool,
93}
94
95impl Default for UpdateConfig {
96    fn default() -> Self {
97        Self {
98            batch_size: DEFAULT_BATCH_SIZE,
99            kmeans_niters: 4,
100            max_points_per_centroid: 256,
101            n_samples_kmeans: None,
102            seed: 42,
103            start_from_scratch: crate::default_start_from_scratch(),
104            buffer_size: 100,
105            force_cpu: false,
106        }
107    }
108}
109
110impl UpdateConfig {
111    /// Convert to ComputeKmeansConfig for centroid expansion.
112    pub fn to_kmeans_config(&self) -> ComputeKmeansConfig {
113        ComputeKmeansConfig {
114            kmeans_niters: self.kmeans_niters,
115            max_points_per_centroid: self.max_points_per_centroid,
116            seed: self.seed,
117            n_samples_kmeans: self.n_samples_kmeans,
118            num_partitions: None,
119            force_cpu: self.force_cpu,
120        }
121    }
122}
123
124// ============================================================================
125// Buffer Management
126// ============================================================================
127
128/// Load buffered embeddings from buffer.npy.
129///
130/// Returns an empty vector if buffer.npy doesn't exist.
131/// Uses buffer_lengths.json to split the flattened array back into per-document arrays.
132pub fn load_buffer(index_path: &Path) -> Result<Vec<Array2<f32>>> {
133    use ndarray_npy::ReadNpyExt;
134
135    let buffer_path = index_path.join("buffer.npy");
136    let lengths_path = index_path.join("buffer_lengths.json");
137
138    if !buffer_path.exists() {
139        return Ok(Vec::new());
140    }
141
142    // Load the flattened embeddings array
143    let flat: Array2<f32> = match Array2::read_npy(File::open(&buffer_path)?) {
144        Ok(arr) => arr,
145        Err(_) => return Ok(Vec::new()),
146    };
147
148    // Load lengths to split back into per-document arrays
149    if lengths_path.exists() {
150        let lengths: Vec<i64> =
151            serde_json::from_reader(BufReader::new(File::open(&lengths_path)?))?;
152
153        let mut result = Vec::with_capacity(lengths.len());
154        let mut offset = 0;
155
156        for &len in &lengths {
157            let len_usize = len as usize;
158            if offset + len_usize > flat.nrows() {
159                break;
160            }
161            let doc_emb = flat.slice(s![offset..offset + len_usize, ..]).to_owned();
162            result.push(doc_emb);
163            offset += len_usize;
164        }
165
166        return Ok(result);
167    }
168
169    // Fallback: if no lengths file, return as single document (legacy behavior)
170    Ok(vec![flat])
171}
172
173/// Save embeddings to buffer.npy.
174///
175/// Also saves buffer_info.json with the number of documents for deletion tracking.
176pub fn save_buffer(index_path: &Path, embeddings: &[Array2<f32>]) -> Result<()> {
177    use ndarray_npy::WriteNpyExt;
178
179    let buffer_path = index_path.join("buffer.npy");
180
181    // For simplicity, concatenate all embeddings into one array
182    // and store the lengths separately
183    if embeddings.is_empty() {
184        return Ok(());
185    }
186
187    let dim = embeddings[0].ncols();
188    let total_rows: usize = embeddings.iter().map(|e| e.nrows()).sum();
189
190    let mut flat = Array2::<f32>::zeros((total_rows, dim));
191    let mut offset = 0;
192    let mut lengths = Vec::new();
193
194    for emb in embeddings {
195        let n = emb.nrows();
196        flat.slice_mut(s![offset..offset + n, ..]).assign(emb);
197        lengths.push(n as i64);
198        offset += n;
199    }
200
201    atomic_write_file(&buffer_path, |file| {
202        flat.write_npy(file)?;
203        Ok(())
204    })?;
205
206    // Save lengths
207    let lengths_path = index_path.join("buffer_lengths.json");
208    atomic_write_file(&lengths_path, |file| {
209        let mut writer = BufWriter::new(file);
210        serde_json::to_writer(&mut writer, &lengths)?;
211        writer.flush()?;
212        Ok(())
213    })?;
214
215    // Save buffer info for deletion tracking (number of documents)
216    let info_path = index_path.join("buffer_info.json");
217    let buffer_info = serde_json::json!({ "num_docs": embeddings.len() });
218    atomic_write_file(&info_path, |file| {
219        let mut writer = BufWriter::new(file);
220        serde_json::to_writer(&mut writer, &buffer_info)?;
221        writer.flush()?;
222        Ok(())
223    })?;
224
225    Ok(())
226}
227
228/// Load buffer info (number of buffered documents).
229///
230/// Returns 0 if buffer_info.json doesn't exist.
231pub fn load_buffer_info(index_path: &Path) -> Result<usize> {
232    let info_path = index_path.join("buffer_info.json");
233    if !info_path.exists() {
234        return Ok(0);
235    }
236
237    let info: serde_json::Value = serde_json::from_reader(BufReader::new(File::open(&info_path)?))?;
238
239    Ok(info.get("num_docs").and_then(|v| v.as_u64()).unwrap_or(0) as usize)
240}
241
242/// Clear buffer files.
243pub fn clear_buffer(index_path: &Path) -> Result<()> {
244    let buffer_path = index_path.join("buffer.npy");
245    let lengths_path = index_path.join("buffer_lengths.json");
246    let info_path = index_path.join("buffer_info.json");
247
248    if buffer_path.exists() {
249        fs::remove_file(&buffer_path)?;
250    }
251    if lengths_path.exists() {
252        fs::remove_file(&lengths_path)?;
253    }
254    if info_path.exists() {
255        fs::remove_file(&info_path)?;
256    }
257
258    Ok(())
259}
260
261/// Load embeddings stored for rebuild (embeddings.npy + embeddings_lengths.json).
262///
263/// This function loads raw embeddings that were saved for start-from-scratch rebuilds.
264/// The embeddings are stored in a flat 2D array with a separate lengths file.
265pub fn load_embeddings_npy(index_path: &Path) -> Result<Vec<Array2<f32>>> {
266    use ndarray_npy::ReadNpyExt;
267
268    let emb_path = index_path.join("embeddings.npy");
269    let lengths_path = index_path.join("embeddings_lengths.json");
270
271    if !emb_path.exists() {
272        return Ok(Vec::new());
273    }
274
275    // Load flat embeddings array
276    let flat: Array2<f32> = Array2::read_npy(File::open(&emb_path)?)?;
277
278    // Load lengths to split back into per-document arrays
279    if lengths_path.exists() {
280        let lengths: Vec<i64> =
281            serde_json::from_reader(BufReader::new(File::open(&lengths_path)?))?;
282
283        let mut result = Vec::with_capacity(lengths.len());
284        let mut offset = 0;
285
286        for &len in &lengths {
287            let len_usize = len as usize;
288            if offset + len_usize > flat.nrows() {
289                break;
290            }
291            let doc_emb = flat.slice(s![offset..offset + len_usize, ..]).to_owned();
292            result.push(doc_emb);
293            offset += len_usize;
294        }
295
296        return Ok(result);
297    }
298
299    // Fallback: if no lengths file, return as single document
300    Ok(vec![flat])
301}
302
303/// Save embeddings for potential rebuild (start-from-scratch mode).
304///
305/// Stores embeddings in embeddings.npy (flat array) + embeddings_lengths.json.
306/// This matches fast-plaid's behavior of storing raw embeddings when the index
307/// is below the start_from_scratch threshold.
308pub fn save_embeddings_npy(index_path: &Path, embeddings: &[Array2<f32>]) -> Result<()> {
309    use ndarray_npy::WriteNpyExt;
310
311    if embeddings.is_empty() {
312        return Ok(());
313    }
314
315    let dim = embeddings[0].ncols();
316    let total_rows: usize = embeddings.iter().map(|e| e.nrows()).sum();
317
318    let mut flat = Array2::<f32>::zeros((total_rows, dim));
319    let mut offset = 0;
320    let mut lengths = Vec::with_capacity(embeddings.len());
321
322    for emb in embeddings {
323        let n = emb.nrows();
324        flat.slice_mut(s![offset..offset + n, ..]).assign(emb);
325        lengths.push(n as i64);
326        offset += n;
327    }
328
329    // Save flat embeddings
330    let emb_path = index_path.join("embeddings.npy");
331    atomic_write_file(&emb_path, |file| {
332        flat.write_npy(file)?;
333        Ok(())
334    })?;
335
336    // Save lengths for reconstruction
337    let lengths_path = index_path.join("embeddings_lengths.json");
338    atomic_write_file(&lengths_path, |file| {
339        let mut writer = BufWriter::new(file);
340        serde_json::to_writer(&mut writer, &lengths)?;
341        writer.flush()?;
342        Ok(())
343    })?;
344
345    Ok(())
346}
347
348/// Clear embeddings.npy and embeddings_lengths.json.
349pub fn clear_embeddings_npy(index_path: &Path) -> Result<()> {
350    let emb_path = index_path.join("embeddings.npy");
351    let lengths_path = index_path.join("embeddings_lengths.json");
352
353    if emb_path.exists() {
354        fs::remove_file(&emb_path)?;
355    }
356    if lengths_path.exists() {
357        fs::remove_file(&lengths_path)?;
358    }
359    Ok(())
360}
361
362/// Check if embeddings.npy exists for start-from-scratch mode.
363pub fn embeddings_npy_exists(index_path: &Path) -> bool {
364    index_path.join("embeddings.npy").exists()
365}
366
367// ============================================================================
368// Cluster Threshold Management
369// ============================================================================
370
371/// Load cluster threshold from cluster_threshold.npy.
372pub fn load_cluster_threshold(index_path: &Path) -> Result<f32> {
373    use ndarray_npy::ReadNpyExt;
374
375    let thresh_path = index_path.join("cluster_threshold.npy");
376    if !thresh_path.exists() {
377        return Err(Error::Update("cluster_threshold.npy not found".into()));
378    }
379
380    let arr: Array1<f32> = Array1::read_npy(File::open(&thresh_path)?)?;
381    Ok(arr[0])
382}
383
384/// Update cluster_threshold.npy with weighted average.
385pub fn update_cluster_threshold(
386    index_path: &Path,
387    new_residual_norms: &Array1<f32>,
388    old_total_embeddings: usize,
389) -> Result<()> {
390    use ndarray_npy::{ReadNpyExt, WriteNpyExt};
391
392    let new_count = new_residual_norms.len();
393    if new_count == 0 {
394        return Ok(());
395    }
396
397    let new_threshold = quantile(new_residual_norms, 0.75);
398
399    let thresh_path = index_path.join("cluster_threshold.npy");
400    let final_threshold = if thresh_path.exists() {
401        let old_arr: Array1<f32> = Array1::read_npy(File::open(&thresh_path)?)?;
402        let old_threshold = old_arr[0];
403        let total = old_total_embeddings + new_count;
404        (old_threshold * old_total_embeddings as f32 + new_threshold * new_count as f32)
405            / total as f32
406    } else {
407        new_threshold
408    };
409
410    atomic_write_file(&thresh_path, |file| {
411        Array1::from_vec(vec![final_threshold]).write_npy(file)?;
412        Ok(())
413    })?;
414
415    Ok(())
416}
417
418// ============================================================================
419// Centroid Expansion
420// ============================================================================
421
422const OUTLIER_CENTROID_TILE: usize = 8;
423const OUTLIER_EMBEDDING_TILE: usize = 64;
424const OUTLIER_BLOCKS_MIN_LEN: usize = 4;
425const OUTLIER_THRESHOLD_RECHECK_REL_EPS: f32 = 1e-5;
426
427#[inline]
428fn squared_norm(row: &[f32]) -> f32 {
429    let mut sum0 = 0.0f32;
430    let mut sum1 = 0.0f32;
431    let mut sum2 = 0.0f32;
432    let mut sum3 = 0.0f32;
433
434    let mut i = 0;
435    while i + 4 <= row.len() {
436        sum0 += row[i] * row[i];
437        sum1 += row[i + 1] * row[i + 1];
438        sum2 += row[i + 2] * row[i + 2];
439        sum3 += row[i + 3] * row[i + 3];
440        i += 4;
441    }
442
443    let mut total = sum0 + sum1 + sum2 + sum3;
444    while i < row.len() {
445        total += row[i] * row[i];
446        i += 1;
447    }
448
449    total
450}
451
452/// Recompute min L2² distance in f64 for borderline candidates.
453/// The f32 tiled loop can produce rounding errors near the threshold;
454/// promoting to f64 here eliminates false positives without slowing
455/// down the fast path (only called for ~1% of embeddings).
456#[inline]
457fn min_distance_sq_precise(row: &[f32], centroids_flat: &[f32], dim: usize) -> f32 {
458    let mut min_dist_sq = f32::INFINITY;
459
460    for centroid in centroids_flat.chunks_exact(dim) {
461        let mut dist_sq = 0.0f64;
462        let mut d = 0;
463        while d < dim {
464            let diff = row[d] as f64 - centroid[d] as f64;
465            dist_sq += diff * diff;
466            d += 1;
467        }
468
469        min_dist_sq = min_dist_sq.min(dist_sq as f32);
470    }
471
472    min_dist_sq
473}
474
475/// Find outliers with a blocked raw-slice kernel over centroid tiles.
476///
477/// This avoids materializing the intermediate [batch, num_centroids] scores matrix,
478/// removes iterator overhead from the hot loop, and parallelizes across embedding blocks.
479/// Find embeddings whose minimum L2² distance to any centroid exceeds `threshold_sq`.
480///
481/// Uses a tiled blocked loop instead of a full [n, k] similarity matrix:
482///   - Outer: blocks of OUTLIER_EMBEDDING_TILE embeddings (parallelized via rayon)
483///   - Inner: tiles of OUTLIER_CENTROID_TILE centroids
484///
485/// This avoids allocating O(n*k) memory and keeps the working set in L1/L2 cache.
486/// The dot-product loop (`dim_idx`) is the innermost to maximize sequential memory
487/// access across the embedding dimension. Borderline results (within 1% of the
488/// threshold) are rechecked in f64 to avoid false positives from f32 rounding.
489#[allow(clippy::needless_range_loop)]
490fn find_outliers(
491    flat_embeddings: &Array2<f32>,
492    centroids: &Array2<f32>,
493    threshold_sq: f32,
494) -> Vec<usize> {
495    let n = flat_embeddings.nrows();
496    let k = centroids.nrows();
497    let dim = flat_embeddings.ncols();
498
499    if n == 0 || k == 0 {
500        return Vec::new();
501    }
502
503    let embeddings_owned;
504    let embeddings_flat = if let Some(slice) = flat_embeddings.as_slice_memory_order() {
505        slice
506    } else {
507        embeddings_owned = flat_embeddings.as_standard_layout().to_owned();
508        embeddings_owned
509            .as_slice_memory_order()
510            .expect("standard-layout embeddings should be contiguous")
511    };
512    let centroids_owned;
513    let centroids_flat = if let Some(slice) = centroids.as_slice_memory_order() {
514        slice
515    } else {
516        centroids_owned = centroids.as_standard_layout().to_owned();
517        centroids_owned
518            .as_slice_memory_order()
519            .expect("standard-layout centroids should be contiguous")
520    };
521
522    let centroid_norms_sq: Vec<f32> = centroids_flat
523        .par_chunks_exact(dim)
524        .map(squared_norm)
525        .collect();
526
527    let row_stride = dim * OUTLIER_EMBEDDING_TILE;
528    embeddings_flat
529        .par_chunks(row_stride)
530        .with_min_len(OUTLIER_BLOCKS_MIN_LEN)
531        .enumerate()
532        .flat_map_iter(|(block_idx, rows_block)| {
533            let row_count = rows_block.len() / dim;
534            let row_offset = block_idx * OUTLIER_EMBEDDING_TILE;
535
536            let mut min_dists = vec![f32::INFINITY; row_count];
537            let emb_norms: Vec<f32> = rows_block.chunks_exact(dim).map(squared_norm).collect();
538
539            let mut centroid_idx = 0;
540            while centroid_idx + OUTLIER_CENTROID_TILE <= k {
541                let centroid_bases: [usize; OUTLIER_CENTROID_TILE] =
542                    std::array::from_fn(|j| (centroid_idx + j) * dim);
543
544                let mut dots = [[0.0f32; OUTLIER_CENTROID_TILE]; OUTLIER_EMBEDDING_TILE];
545
546                let mut dim_idx = 0;
547                while dim_idx < dim {
548                    let centroid_vals: [f32; OUTLIER_CENTROID_TILE] =
549                        std::array::from_fn(|j| centroids_flat[centroid_bases[j] + dim_idx]);
550
551                    for row_idx in 0..row_count {
552                        let x = rows_block[row_idx * dim + dim_idx];
553                        for j in 0..OUTLIER_CENTROID_TILE {
554                            dots[row_idx][j] += x * centroid_vals[j];
555                        }
556                    }
557
558                    dim_idx += 1;
559                }
560
561                for row_idx in 0..row_count {
562                    let emb_norm_sq = emb_norms[row_idx];
563                    for j in 0..OUTLIER_CENTROID_TILE {
564                        let dist_sq = emb_norm_sq + centroid_norms_sq[centroid_idx + j]
565                            - 2.0 * dots[row_idx][j];
566                        min_dists[row_idx] = min_dists[row_idx].min(dist_sq);
567                    }
568                }
569
570                centroid_idx += OUTLIER_CENTROID_TILE;
571            }
572
573            while centroid_idx < k {
574                let centroid = &centroids_flat[centroid_idx * dim..(centroid_idx + 1) * dim];
575                for row_idx in 0..row_count {
576                    let row = &rows_block[row_idx * dim..(row_idx + 1) * dim];
577                    let mut dot = 0.0f32;
578                    let mut dim_idx = 0;
579                    while dim_idx < dim {
580                        dot += row[dim_idx] * centroid[dim_idx];
581                        dim_idx += 1;
582                    }
583
584                    let dist_sq = emb_norms[row_idx] + centroid_norms_sq[centroid_idx] - 2.0 * dot;
585                    min_dists[row_idx] = min_dists[row_idx].min(dist_sq);
586                }
587
588                centroid_idx += 1;
589            }
590
591            min_dists
592                .into_iter()
593                .enumerate()
594                .filter_map(move |(row_idx, min_dist_sq)| {
595                    let final_min_dist_sq = if (min_dist_sq - threshold_sq).abs()
596                        <= threshold_sq.abs().max(1.0) * OUTLIER_THRESHOLD_RECHECK_REL_EPS
597                    {
598                        let row = &rows_block[row_idx * dim..(row_idx + 1) * dim];
599                        min_distance_sq_precise(row, centroids_flat, dim)
600                    } else {
601                        min_dist_sq
602                    };
603
604                    (final_min_dist_sq > threshold_sq).then_some(row_offset + row_idx)
605                })
606        })
607        .collect()
608}
609
610/// Expand centroids by clustering embeddings far from existing centroids.
611///
612/// This implements fast-plaid's update_centroids() function:
613/// 1. Flatten all new embeddings
614/// 2. Find outliers (distance > cluster_threshold²)
615/// 3. Cluster outliers to get new centroids
616/// 4. Append new centroids to centroids.npy
617/// 5. Extend ivf_lengths.npy with zeros
618/// 6. Update metadata.json num_partitions
619///
620/// Returns the number of new centroids added.
621pub fn update_centroids(
622    index_path: &Path,
623    new_embeddings: &[Array2<f32>],
624    cluster_threshold: f32,
625    config: &UpdateConfig,
626) -> Result<usize> {
627    use ndarray_npy::{ReadNpyExt, WriteNpyExt};
628
629    let centroids_path = index_path.join("centroids.npy");
630    if !centroids_path.exists() {
631        return Ok(0);
632    }
633
634    // Load existing centroids
635    let existing_centroids: Array2<f32> = Array2::read_npy(File::open(&centroids_path)?)?;
636
637    // Flatten all new embeddings
638    let dim = existing_centroids.ncols();
639    let total_tokens: usize = new_embeddings.iter().map(|e| e.nrows()).sum();
640
641    if total_tokens == 0 {
642        return Ok(0);
643    }
644
645    let mut flat_embeddings = Array2::<f32>::zeros((total_tokens, dim));
646    let mut offset = 0;
647
648    for emb in new_embeddings {
649        let n = emb.nrows();
650        flat_embeddings
651            .slice_mut(s![offset..offset + n, ..])
652            .assign(emb);
653        offset += n;
654    }
655
656    // Find outliers
657    emit_update_progress(
658        "centroid_expansion",
659        "finding embeddings outside existing centroids",
660    );
661    let threshold_sq = cluster_threshold * cluster_threshold;
662    let outlier_indices = find_outliers(&flat_embeddings, &existing_centroids, threshold_sq);
663
664    let num_outliers = outlier_indices.len();
665    if num_outliers == 0 {
666        return Ok(0);
667    }
668
669    // Extract outlier embeddings
670    let mut outliers = Array2::<f32>::zeros((num_outliers, dim));
671    for (i, &idx) in outlier_indices.iter().enumerate() {
672        outliers.row_mut(i).assign(&flat_embeddings.row(idx));
673    }
674
675    // Compute number of new centroids
676    // k_update = max(1, ceil(num_outliers / max_points_per_centroid) * 4)
677    let target_k =
678        ((num_outliers as f64 / config.max_points_per_centroid as f64).ceil() as usize).max(1) * 4;
679    let k_update = target_k.min(num_outliers); // Can't have more centroids than points
680
681    // Cluster outliers to get new centroids
682    let kmeans_config = ComputeKmeansConfig {
683        kmeans_niters: config.kmeans_niters,
684        max_points_per_centroid: config.max_points_per_centroid,
685        seed: config.seed,
686        n_samples_kmeans: config.n_samples_kmeans,
687        num_partitions: Some(k_update),
688        force_cpu: config.force_cpu,
689    };
690
691    // Convert outliers to vector of single-token "documents" for compute_kmeans
692    let outlier_docs: Vec<Array2<f32>> = outlier_indices
693        .iter()
694        .map(|&idx| flat_embeddings.slice(s![idx..idx + 1, ..]).to_owned())
695        .collect();
696
697    emit_update_progress("kmeans", "clustering outlier embeddings");
698    let new_centroids = compute_kmeans(&outlier_docs, &kmeans_config)?;
699    let k_new = new_centroids.nrows();
700
701    // Concatenate centroids
702    let new_num_centroids = existing_centroids.nrows() + k_new;
703    let mut final_centroids = Array2::<f32>::zeros((new_num_centroids, dim));
704    final_centroids
705        .slice_mut(s![..existing_centroids.nrows(), ..])
706        .assign(&existing_centroids);
707    final_centroids
708        .slice_mut(s![existing_centroids.nrows().., ..])
709        .assign(&new_centroids);
710
711    // Save updated centroids
712    emit_update_progress("index_write", "writing updated centroids");
713    atomic_write_file(&centroids_path, |file| {
714        final_centroids.write_npy(file)?;
715        Ok(())
716    })?;
717
718    // Extend ivf_lengths.npy with zeros for new centroids
719    let ivf_lengths_path = index_path.join("ivf_lengths.npy");
720    if ivf_lengths_path.exists() {
721        let old_lengths: Array1<i32> = Array1::read_npy(File::open(&ivf_lengths_path)?)?;
722        let mut new_lengths = Array1::<i32>::zeros(new_num_centroids);
723        new_lengths
724            .slice_mut(s![..old_lengths.len()])
725            .assign(&old_lengths);
726        atomic_write_file(&ivf_lengths_path, |file| {
727            new_lengths.write_npy(file)?;
728            Ok(())
729        })?;
730    }
731
732    // Update metadata.json num_partitions
733    let meta_path = index_path.join("metadata.json");
734    if meta_path.exists() {
735        let mut meta: serde_json::Value =
736            serde_json::from_reader(BufReader::new(File::open(&meta_path)?))?;
737
738        if let Some(obj) = meta.as_object_mut() {
739            obj.insert("num_partitions".to_string(), new_num_centroids.into());
740        }
741
742        atomic_write_file(&meta_path, |file| {
743            let mut writer = BufWriter::new(file);
744            serde_json::to_writer_pretty(&mut writer, &meta)?;
745            writer.flush()?;
746            Ok(())
747        })?;
748    }
749
750    Ok(k_new)
751}
752
753// ============================================================================
754// Low-Level Index Update
755// ============================================================================
756
757/// Update an existing index with new documents.
758///
759/// # Arguments
760///
761/// * `embeddings` - List of new document embeddings, each of shape `[num_tokens, dim]`
762/// * `index_path` - Path to the existing index directory
763/// * `codec` - The loaded ResidualCodec for compression
764/// * `batch_size` - Optional batch size for processing (default: 50,000)
765/// * `update_threshold` - Whether to update the cluster threshold
766/// * `force_cpu` - Force CPU execution even when CUDA is available
767///
768/// # Returns
769///
770/// The number of new documents added
771pub fn update_index(
772    embeddings: &[Array2<f32>],
773    index_path: &str,
774    codec: &ResidualCodec,
775    batch_size: Option<usize>,
776    update_threshold: bool,
777    force_cpu: bool,
778) -> Result<usize> {
779    emit_update_progress("index_write", "writing index chunks");
780    let batch_size = batch_size.unwrap_or(DEFAULT_BATCH_SIZE);
781    let index_dir = Path::new(index_path);
782
783    // Load existing metadata (infers num_documents from doclens if not present)
784    let metadata_path = index_dir.join("metadata.json");
785    let metadata = Metadata::load_from_path(index_dir)?;
786
787    let num_existing_chunks = metadata.num_chunks;
788    let old_num_documents = metadata.num_documents;
789    let old_total_embeddings = metadata.num_embeddings;
790    let num_centroids = codec.num_centroids();
791    let embedding_dim = codec.embedding_dim();
792    let nbits = metadata.nbits;
793
794    // Determine starting chunk index
795    let mut start_chunk_idx = num_existing_chunks;
796    let mut append_to_last = false;
797    let mut current_emb_offset = old_total_embeddings;
798
799    // Check if we should append to the last chunk (if it has < 2000 documents)
800    if start_chunk_idx > 0 {
801        let last_idx = start_chunk_idx - 1;
802        let last_meta_path = index_dir.join(format!("{}.metadata.json", last_idx));
803
804        if last_meta_path.exists() {
805            let last_meta: serde_json::Value =
806                serde_json::from_reader(BufReader::new(File::open(&last_meta_path).map_err(
807                    |e| Error::IndexLoad(format!("Failed to open chunk metadata: {}", e)),
808                )?))?;
809
810            if let Some(nd) = last_meta.get("num_documents").and_then(|x| x.as_u64()) {
811                if nd < 2000 {
812                    start_chunk_idx = last_idx;
813                    append_to_last = true;
814
815                    if let Some(off) = last_meta.get("embedding_offset").and_then(|x| x.as_u64()) {
816                        current_emb_offset = off as usize;
817                    } else {
818                        let embs_in_last = last_meta
819                            .get("num_embeddings")
820                            .and_then(|x| x.as_u64())
821                            .unwrap_or(0) as usize;
822                        current_emb_offset = old_total_embeddings - embs_in_last;
823                    }
824                }
825            }
826        }
827    }
828
829    // Process new documents
830    let num_new_documents = embeddings.len();
831    let n_new_chunks = (num_new_documents as f64 / batch_size as f64).ceil() as usize;
832
833    let mut new_codes_accumulated: Vec<Vec<usize>> = Vec::new();
834    let mut new_doclens_accumulated: Vec<i64> = Vec::new();
835    let mut all_residual_norms: Vec<f32> = Vec::new();
836
837    let packed_dim = embedding_dim * nbits / 8;
838
839    for i in 0..n_new_chunks {
840        let global_chunk_idx = start_chunk_idx + i;
841        let chk_offset = i * batch_size;
842        let chk_end = (chk_offset + batch_size).min(num_new_documents);
843        let chunk_docs = &embeddings[chk_offset..chk_end];
844
845        // Collect document lengths
846        let mut chk_doclens: Vec<i64> = chunk_docs.iter().map(|d| d.nrows() as i64).collect();
847        let total_tokens: usize = chk_doclens.iter().sum::<i64>() as usize;
848
849        // Concatenate all embeddings in the chunk for batch processing
850        let mut batch_embeddings = ndarray::Array2::<f32>::zeros((total_tokens, embedding_dim));
851        let mut offset = 0;
852        for doc in chunk_docs {
853            let n = doc.nrows();
854            batch_embeddings
855                .slice_mut(s![offset..offset + n, ..])
856                .assign(doc);
857            offset += n;
858        }
859
860        // BATCH: Compress all embeddings at once
861        // Use CPU-only version when force_cpu is set to avoid CUDA initialization overhead
862        let batch_codes = if force_cpu {
863            codec.compress_into_codes_cpu(&batch_embeddings)
864        } else {
865            codec.compress_into_codes(&batch_embeddings)
866        };
867
868        // BATCH: Compute residuals using parallel subtraction
869        let mut batch_residuals = batch_embeddings;
870        {
871            let centroids = &codec.centroids;
872            batch_residuals
873                .axis_iter_mut(Axis(0))
874                .into_par_iter()
875                .zip(batch_codes.as_slice().unwrap().par_iter())
876                .for_each(|(mut row, &code)| {
877                    let centroid = centroids.row(code);
878                    row.iter_mut()
879                        .zip(centroid.iter())
880                        .for_each(|(r, c)| *r -= c);
881                });
882        }
883
884        // Collect residual norms if updating threshold
885        if update_threshold {
886            for row in batch_residuals.axis_iter(Axis(0)) {
887                let norm = row.dot(&row).sqrt();
888                all_residual_norms.push(norm);
889            }
890        }
891
892        // BATCH: Quantize all residuals at once
893        let batch_packed = codec.quantize_residuals(&batch_residuals)?;
894
895        // Convert to lists for chunk saving
896        let mut chk_codes_list: Vec<usize> = batch_codes.iter().copied().collect();
897        let mut chk_residuals_list: Vec<u8> = batch_packed.iter().copied().collect();
898
899        // Split codes back into per-document arrays for IVF building
900        let mut code_offset = 0;
901        for &len in &chk_doclens {
902            let len_usize = len as usize;
903            let codes: Vec<usize> = batch_codes
904                .slice(s![code_offset..code_offset + len_usize])
905                .iter()
906                .copied()
907                .collect();
908            new_codes_accumulated.push(codes);
909            new_doclens_accumulated.push(len);
910            code_offset += len_usize;
911        }
912
913        // Handle appending to last chunk
914        if i == 0 && append_to_last {
915            use ndarray_npy::ReadNpyExt;
916
917            let old_doclens_path = index_dir.join(format!("doclens.{}.json", global_chunk_idx));
918
919            if old_doclens_path.exists() {
920                let old_doclens: Vec<i64> =
921                    serde_json::from_reader(BufReader::new(File::open(&old_doclens_path)?))?;
922
923                let old_codes_path = index_dir.join(format!("{}.codes.npy", global_chunk_idx));
924                let old_residuals_path =
925                    index_dir.join(format!("{}.residuals.npy", global_chunk_idx));
926
927                let old_codes: Array1<i64> = Array1::read_npy(File::open(&old_codes_path)?)?;
928                let old_residuals: Array2<u8> = Array2::read_npy(File::open(&old_residuals_path)?)?;
929
930                // Prepend old data
931                let mut combined_codes: Vec<usize> =
932                    old_codes.iter().map(|&x| x as usize).collect();
933                combined_codes.extend(chk_codes_list);
934                chk_codes_list = combined_codes;
935
936                let mut combined_residuals: Vec<u8> = old_residuals.iter().copied().collect();
937                combined_residuals.extend(chk_residuals_list);
938                chk_residuals_list = combined_residuals;
939
940                let mut combined_doclens = old_doclens;
941                combined_doclens.extend(chk_doclens);
942                chk_doclens = combined_doclens;
943            }
944        }
945
946        // Save chunk data
947        {
948            use ndarray_npy::WriteNpyExt;
949
950            let codes_arr: Array1<i64> = chk_codes_list.iter().map(|&x| x as i64).collect();
951            let codes_path = index_dir.join(format!("{}.codes.npy", global_chunk_idx));
952            atomic_write_file(&codes_path, |file| {
953                codes_arr.write_npy(file)?;
954                Ok(())
955            })?;
956
957            let num_tokens = chk_codes_list.len();
958            let residuals_arr =
959                Array2::from_shape_vec((num_tokens, packed_dim), chk_residuals_list)
960                    .map_err(|e| Error::Shape(format!("Failed to reshape residuals: {}", e)))?;
961            let residuals_path = index_dir.join(format!("{}.residuals.npy", global_chunk_idx));
962            atomic_write_file(&residuals_path, |file| {
963                residuals_arr.write_npy(file)?;
964                Ok(())
965            })?;
966        }
967
968        // Save doclens
969        let doclens_path = index_dir.join(format!("doclens.{}.json", global_chunk_idx));
970        atomic_write_file(&doclens_path, |file| {
971            let mut writer = BufWriter::new(file);
972            serde_json::to_writer(&mut writer, &chk_doclens)?;
973            writer.flush()?;
974            Ok(())
975        })?;
976
977        // Save chunk metadata
978        let chk_meta = serde_json::json!({
979            "num_documents": chk_doclens.len(),
980            "num_embeddings": chk_codes_list.len(),
981            "embedding_offset": current_emb_offset,
982        });
983        current_emb_offset += chk_codes_list.len();
984
985        let meta_path = index_dir.join(format!("{}.metadata.json", global_chunk_idx));
986        atomic_write_file(&meta_path, |file| {
987            let mut writer = BufWriter::new(file);
988            serde_json::to_writer_pretty(&mut writer, &chk_meta)?;
989            writer.flush()?;
990            Ok(())
991        })?;
992    }
993
994    // Update cluster threshold if requested
995    if update_threshold && !all_residual_norms.is_empty() {
996        let norms = Array1::from_vec(all_residual_norms);
997        update_cluster_threshold(index_dir, &norms, old_total_embeddings)?;
998    }
999
1000    // Build new partial IVF
1001    let mut partition_pids_map: HashMap<usize, Vec<i64>> = HashMap::new();
1002
1003    for (pid_counter, doc_codes) in (old_num_documents as i64..).zip(new_codes_accumulated.iter()) {
1004        for &code in doc_codes {
1005            partition_pids_map
1006                .entry(code)
1007                .or_default()
1008                .push(pid_counter);
1009        }
1010    }
1011
1012    // Load old IVF and merge
1013    {
1014        use ndarray_npy::{ReadNpyExt, WriteNpyExt};
1015
1016        let ivf_path = index_dir.join("ivf.npy");
1017        let ivf_lengths_path = index_dir.join("ivf_lengths.npy");
1018
1019        let old_ivf: Array1<i64> = if ivf_path.exists() {
1020            Array1::read_npy(File::open(&ivf_path)?)?
1021        } else {
1022            Array1::zeros(0)
1023        };
1024
1025        let old_ivf_lengths: Array1<i32> = if ivf_lengths_path.exists() {
1026            Array1::read_npy(File::open(&ivf_lengths_path)?)?
1027        } else {
1028            Array1::zeros(num_centroids)
1029        };
1030
1031        // Compute old offsets
1032        let mut old_offsets = vec![0i64];
1033        for &len in old_ivf_lengths.iter() {
1034            old_offsets.push(old_offsets.last().unwrap() + len as i64);
1035        }
1036
1037        // Merge IVF
1038        let mut new_ivf_data: Vec<i64> = Vec::new();
1039        let mut new_ivf_lengths: Vec<i32> = Vec::with_capacity(num_centroids);
1040
1041        for centroid_id in 0..num_centroids {
1042            // Get old PIDs for this centroid
1043            let old_start = old_offsets[centroid_id] as usize;
1044            let old_len = if centroid_id < old_ivf_lengths.len() {
1045                old_ivf_lengths[centroid_id] as usize
1046            } else {
1047                0
1048            };
1049
1050            let mut pids: Vec<i64> = if old_len > 0 && old_start + old_len <= old_ivf.len() {
1051                old_ivf.slice(s![old_start..old_start + old_len]).to_vec()
1052            } else {
1053                Vec::new()
1054            };
1055
1056            // Add new PIDs
1057            if let Some(new_pids) = partition_pids_map.get(&centroid_id) {
1058                pids.extend(new_pids);
1059            }
1060
1061            // Deduplicate and sort
1062            pids.sort_unstable();
1063            pids.dedup();
1064
1065            new_ivf_lengths.push(pids.len() as i32);
1066            new_ivf_data.extend(pids);
1067        }
1068
1069        // Save updated IVF
1070        let new_ivf = Array1::from_vec(new_ivf_data);
1071        atomic_write_file(&ivf_path, |file| {
1072            new_ivf.write_npy(file)?;
1073            Ok(())
1074        })?;
1075
1076        let new_lengths = Array1::from_vec(new_ivf_lengths);
1077        atomic_write_file(&ivf_lengths_path, |file| {
1078            new_lengths.write_npy(file)?;
1079            Ok(())
1080        })?;
1081    }
1082
1083    // Update global metadata
1084    let new_total_chunks = start_chunk_idx + n_new_chunks;
1085    let new_tokens_count: i64 = new_doclens_accumulated.iter().sum();
1086    let num_embeddings = old_total_embeddings + new_tokens_count as usize;
1087    let total_num_documents = old_num_documents + num_new_documents;
1088
1089    let new_avg_doclen = if total_num_documents > 0 {
1090        let old_sum = metadata.avg_doclen * old_num_documents as f64;
1091        (old_sum + new_tokens_count as f64) / total_num_documents as f64
1092    } else {
1093        0.0
1094    };
1095
1096    let new_metadata = Metadata {
1097        num_chunks: new_total_chunks,
1098        nbits,
1099        num_partitions: num_centroids,
1100        num_embeddings,
1101        avg_doclen: new_avg_doclen,
1102        num_documents: total_num_documents,
1103        embedding_dim,
1104        next_plaid_compatible: true,
1105    };
1106
1107    emit_update_progress("metadata_write", "writing index metadata");
1108    atomic_write_file(&metadata_path, |file| {
1109        let mut writer = BufWriter::new(file);
1110        serde_json::to_writer_pretty(&mut writer, &new_metadata)?;
1111        writer.flush()?;
1112        Ok(())
1113    })?;
1114
1115    // Clear merged files to force regeneration on next load.
1116    // This ensures the merged files are consistent with the new chunk data.
1117    crate::mmap::clear_merged_files(index_dir)?;
1118
1119    Ok(num_new_documents)
1120}
1121
1122#[cfg(test)]
1123mod tests {
1124    use super::*;
1125    use std::sync::{
1126        atomic::{AtomicUsize, Ordering},
1127        Arc,
1128    };
1129
1130    #[test]
1131    fn test_update_config_default() {
1132        let config = UpdateConfig::default();
1133        assert_eq!(config.batch_size, 50_000);
1134        assert_eq!(config.buffer_size, 100);
1135        assert_eq!(
1136            config.start_from_scratch,
1137            crate::default_start_from_scratch()
1138        );
1139    }
1140
1141    #[test]
1142    fn test_update_progress_clears_after_panic() {
1143        let stale_count = Arc::new(AtomicUsize::new(0));
1144        let stale_count_for_callback = Arc::clone(&stale_count);
1145        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1146            with_update_progress(
1147                move |_, _| {
1148                    stale_count_for_callback.fetch_add(1, Ordering::SeqCst);
1149                },
1150                || panic!("forced progress panic"),
1151            );
1152        }));
1153
1154        assert!(result.is_err());
1155
1156        emit_update_progress("after_panic", "must not hit stale callback");
1157        assert_eq!(stale_count.load(Ordering::SeqCst), 0);
1158
1159        let fresh_count = Arc::new(AtomicUsize::new(0));
1160        let fresh_count_for_callback = Arc::clone(&fresh_count);
1161        with_update_progress(
1162            move |_, _| {
1163                fresh_count_for_callback.fetch_add(1, Ordering::SeqCst);
1164            },
1165            || emit_update_progress("fresh", "fresh callback"),
1166        );
1167        assert_eq!(fresh_count.load(Ordering::SeqCst), 1);
1168    }
1169
1170    #[test]
1171    fn test_find_outliers() {
1172        // Create centroids at (0,0), (1,1)
1173        let centroids = Array2::from_shape_vec((2, 2), vec![0.0, 0.0, 1.0, 1.0]).unwrap();
1174
1175        // Create embeddings: one close to (0,0), one close to (1,1), one far away at (5,5)
1176        let embeddings =
1177            Array2::from_shape_vec((3, 2), vec![0.1, 0.1, 0.9, 0.9, 5.0, 5.0]).unwrap();
1178
1179        // Threshold of 1.0 squared = 1.0
1180        let outliers = find_outliers(&embeddings, &centroids, 1.0);
1181
1182        // Only the point at (5,5) should be an outlier
1183        assert_eq!(outliers.len(), 1);
1184        assert_eq!(outliers[0], 2);
1185    }
1186
1187    #[test]
1188    fn test_buffer_roundtrip() {
1189        use tempfile::TempDir;
1190
1191        let dir = TempDir::new().unwrap();
1192
1193        // Create 3 documents with different numbers of embeddings
1194        let embeddings = vec![
1195            Array2::from_shape_vec((3, 4), (0..12).map(|x| x as f32).collect()).unwrap(),
1196            Array2::from_shape_vec((2, 4), (12..20).map(|x| x as f32).collect()).unwrap(),
1197            Array2::from_shape_vec((5, 4), (20..40).map(|x| x as f32).collect()).unwrap(),
1198        ];
1199
1200        // Save buffer
1201        save_buffer(dir.path(), &embeddings).unwrap();
1202
1203        // Load buffer and verify we get 3 documents, not 1
1204        let loaded = load_buffer(dir.path()).unwrap();
1205
1206        assert_eq!(loaded.len(), 3, "Should have 3 documents, not 1");
1207        assert_eq!(loaded[0].nrows(), 3, "First doc should have 3 rows");
1208        assert_eq!(loaded[1].nrows(), 2, "Second doc should have 2 rows");
1209        assert_eq!(loaded[2].nrows(), 5, "Third doc should have 5 rows");
1210
1211        // Verify content matches
1212        assert_eq!(loaded[0], embeddings[0]);
1213        assert_eq!(loaded[1], embeddings[1]);
1214        assert_eq!(loaded[2], embeddings[2]);
1215    }
1216
1217    #[test]
1218    fn test_buffer_info_matches_buffer_len() {
1219        use tempfile::TempDir;
1220
1221        let dir = TempDir::new().unwrap();
1222
1223        // Create 5 documents
1224        let embeddings: Vec<Array2<f32>> = (0..5)
1225            .map(|i| {
1226                let rows = i + 2; // 2, 3, 4, 5, 6 rows
1227                Array2::from_shape_fn((rows, 4), |(r, c)| (r * 4 + c) as f32)
1228            })
1229            .collect();
1230
1231        save_buffer(dir.path(), &embeddings).unwrap();
1232
1233        // Verify buffer_info.json matches actual document count
1234        let info_count = load_buffer_info(dir.path()).unwrap();
1235        let loaded = load_buffer(dir.path()).unwrap();
1236
1237        assert_eq!(info_count, 5, "buffer_info should report 5 docs");
1238        assert_eq!(
1239            loaded.len(),
1240            5,
1241            "load_buffer should return 5 docs to match buffer_info"
1242        );
1243    }
1244}