Skip to main content

hermes_core/index/
vector_builder.rs

1//! Vector index building for IndexWriter
2//!
3//! Training is **manual-only** — decoupled from commit.
4//! `build_vector_index()` trains missing coarse-centroid generations;
5//! `retrain_vector_index()` replaces them. Both finish every committed ANN
6//! segment. Leaf codecs (TurboQuant) are derived, never trained.
7
8use std::io::Write;
9use std::sync::Arc;
10
11use rustc_hash::FxHashMap;
12
13use crate::directories::DirectoryWriter;
14use crate::dsl::{
15    BinaryDenseVectorConfig, BinaryIndexType, DenseVectorConfig, Field, FieldType, VectorIndexType,
16};
17use crate::error::{Error, Result};
18use crate::segment::{SegmentFiles, SegmentId, SegmentMeta};
19
20use super::IndexWriter;
21
22/// Maximum supported IVF centroid count. Query-side `nprobe` and serialized
23/// cluster identifiers use the same practical bound.
24const MAX_IVF_CLUSTERS: usize = 1_048_576;
25/// Faiss-style clustering quality floor: fewer points per centroid generally
26/// overfits the training sample and leaves unstable/empty cells.
27const MIN_TRAINING_POINTS_PER_CENTROID: usize = 39;
28/// Faiss-style clustering ceiling: more points per centroid multiply Lloyd
29/// cost without materially improving the codebook.
30const COARSE_TRAINING_POINTS_PER_CENTROID: usize = 256;
31/// Bound transient I/O/dequantization buffers independently of the configured
32/// total training sample budget.
33const MAX_SAMPLE_READ_BYTES: usize = 64 * 1024 * 1024;
34/// Coalesce nearby point-sample reads only while the extra I/O remains bounded.
35/// This keeps point-level sampling statistically useful without turning a dense
36/// sample into one range read per vector.
37const MAX_SAMPLE_READ_AMPLIFICATION: usize = 4;
38/// A held-out sample is large enough to expose routing/occupancy tails but stays
39/// bounded when codebooks are trained from millions of points.
40const VALIDATION_SAMPLE_DENOMINATOR: usize = 10;
41const MAX_VALIDATION_SAMPLES: usize = 65_536;
42/// Bound the exact centroid scan used to measure router recall. Even for very
43/// large codebooks, retain at least one held-out row when the sample permits.
44const MAX_VALIDATION_COORDINATE_WORK: usize = 512_000_000;
45/// A second deterministic initialization is useful on modest training jobs.
46/// Large jobs retain the same quality-report scaffold without silently doubling
47/// their already substantial Lloyd cost.
48const MODEL_SELECTION_SEEDS: [u64; 2] = [42, 0x9e37_79b9_7f4a_7c15];
49const MAX_MULTI_SEED_COORDINATE_WORK: usize = 4_000_000_000;
50/// Generation-qualified filenames make retraining crash-safe: the currently
51/// published metadata never points at a file being overwritten in place.
52const VECTOR_ARTIFACT_PREFIX: &str = "vector_artifact_";
53
54struct TrainedFieldUpdate {
55    field_id: u32,
56    index_type: super::metadata::VectorFieldIndexType,
57    vector_count: usize,
58    num_clusters: usize,
59    centroids_file: String,
60    codebook_file: Option<String>,
61}
62
63enum TrainedFieldArtifacts {
64    /// IVF-TQ: only the coarse router is trained; the TQ leaf codec is
65    /// derived from the dimension.
66    FloatCentroids(crate::structures::CoarseCentroids),
67    Binary(crate::structures::BinaryCoarseQuantizer),
68}
69
70struct TrainedFieldModel {
71    update: TrainedFieldUpdate,
72    artifacts: TrainedFieldArtifacts,
73}
74
75#[derive(Clone)]
76enum IvfFieldConfig {
77    Float(DenseVectorConfig),
78    Binary(BinaryDenseVectorConfig),
79}
80
81impl IvfFieldConfig {
82    fn dim(&self) -> usize {
83        match self {
84            Self::Float(config) => config.dim,
85            Self::Binary(config) => config.dim,
86        }
87    }
88
89    fn index_type(&self) -> super::metadata::VectorFieldIndexType {
90        match self {
91            Self::Float(config) => config.index_type.into(),
92            Self::Binary(config) => config.index_type.into(),
93        }
94    }
95
96    fn num_clusters(&self) -> Option<usize> {
97        match self {
98            Self::Float(config) => config.num_clusters,
99            Self::Binary(config) => config.num_clusters,
100        }
101    }
102
103    fn optimal_num_clusters(&self, vector_count: usize) -> usize {
104        match self {
105            Self::Float(config) => config.optimal_num_clusters(vector_count),
106            Self::Binary(config) => config.optimal_num_clusters(vector_count),
107        }
108    }
109}
110
111enum TrainingSample {
112    /// Contiguous row-major matrix, retained in this form through training.
113    Float(Vec<f32>),
114    Binary(Vec<u8>),
115}
116
117#[derive(Clone, Copy, Debug)]
118struct OccupancyQuality {
119    p95: usize,
120    p99: usize,
121    max: usize,
122    empty: usize,
123    penalty: f64,
124}
125
126#[derive(Clone, Copy, Debug)]
127struct FloatBuildQuality {
128    objective: f64,
129    mean_exact_distortion: f64,
130    mean_routed_distortion: f64,
131    router_recall_at_1: f64,
132    mean_construction_assignments: f64,
133    residual_p50: f32,
134    residual_p95: f32,
135    residual_p99: f32,
136    occupancy: OccupancyQuality,
137}
138
139#[derive(Clone, Copy, Debug, Eq, PartialEq)]
140enum VectorGenerationMode {
141    BuildMissing,
142    RetrainAll,
143}
144
145impl TrainingSample {
146    fn len(&self, dim: usize) -> usize {
147        match self {
148            Self::Float(values) => values.len() / dim,
149            Self::Binary(codes) => codes.len() / dim.div_ceil(8),
150        }
151    }
152}
153
154/// Write adapter that rejects an artifact before its serialized form exceeds
155/// the same bound enforced by the loader. Encoding directly through this
156/// adapter avoids materializing a second, potentially hundreds-of-megabytes
157/// copy of the trained structure.
158struct SizeLimitedWriter<'a, W: Write + ?Sized> {
159    inner: &'a mut W,
160    written: usize,
161    limit: usize,
162}
163
164impl<'a, W: Write + ?Sized> SizeLimitedWriter<'a, W> {
165    fn new(inner: &'a mut W, limit: usize) -> Self {
166        Self {
167            inner,
168            written: 0,
169            limit,
170        }
171    }
172}
173
174impl<W: Write + ?Sized> Write for SizeLimitedWriter<'_, W> {
175    fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
176        let next_size = self
177            .written
178            .checked_add(buffer.len())
179            .ok_or_else(|| std::io::Error::other("trained artifact size overflow"))?;
180        if next_size > self.limit {
181            return Err(std::io::Error::new(
182                std::io::ErrorKind::InvalidData,
183                format!(
184                    "trained artifact exceeds the {}-byte safety limit",
185                    self.limit
186                ),
187            ));
188        }
189        let written = self.inner.write(buffer)?;
190        self.written += written;
191        Ok(written)
192    }
193
194    fn flush(&mut self) -> std::io::Result<()> {
195        self.inner.flush()
196    }
197}
198
199fn validate_explicit_cluster_count(num_clusters: Option<usize>) -> Result<()> {
200    match num_clusters {
201        Some(0) => Err(Error::Schema(
202            "dense vector num_clusters must be at least 1".to_string(),
203        )),
204        Some(value) if value > MAX_IVF_CLUSTERS => Err(Error::Schema(format!(
205            "dense vector num_clusters must not exceed {MAX_IVF_CLUSTERS}, got {value}"
206        ))),
207        _ => Ok(()),
208    }
209}
210
211fn effective_field_num_clusters(
212    config: &IvfFieldConfig,
213    corpus_count: usize,
214    sample_count: usize,
215) -> Result<usize> {
216    if sample_count == 0 {
217        return Err(Error::Schema(
218            "cannot train an IVF vector index without sample vectors".to_string(),
219        ));
220    }
221    validate_explicit_cluster_count(config.num_clusters())?;
222    let centroid_bytes = match config {
223        IvfFieldConfig::Float(config) => config.dim.saturating_mul(size_of::<f32>()),
224        IvfFieldConfig::Binary(config) => config.dim.div_ceil(8),
225    };
226    let artifact_limit = super::metadata::MAX_TRAINED_ARTIFACT_BYTES
227        .saturating_sub(1024)
228        .checked_div(centroid_bytes.max(1))
229        .unwrap_or(0)
230        .max(1);
231    let quality_limit = if config.num_clusters().is_some() {
232        sample_count
233    } else {
234        (sample_count / MIN_TRAINING_POINTS_PER_CENTROID)
235            .max(16)
236            .min(sample_count)
237    };
238    let requested = config.optimal_num_clusters(corpus_count);
239    if config.num_clusters().is_some() && requested > artifact_limit {
240        return Err(Error::Schema(format!(
241            "configured IVF codebook needs {} bytes for {} centroids, exceeding the {}-byte artifact limit",
242            requested.saturating_mul(centroid_bytes),
243            requested,
244            super::metadata::MAX_TRAINED_ARTIFACT_BYTES,
245        )));
246    }
247    Ok(requested.min(quality_limit).min(artifact_limit))
248}
249
250fn training_sample_limit(
251    max_samples: usize,
252    max_bytes: usize,
253    bytes_per_sample: usize,
254) -> Result<usize> {
255    if max_samples == 0 || max_bytes == 0 || bytes_per_sample == 0 {
256        return Err(Error::Schema(
257            "vector training sample count, memory budget, and vector size must be greater than zero"
258                .into(),
259        ));
260    }
261    let memory_limited = max_bytes / bytes_per_sample;
262    if memory_limited == 0 {
263        return Err(Error::Schema(format!(
264            "vector training memory budget ({max_bytes} bytes) cannot hold one {bytes_per_sample}-byte sample"
265        )));
266    }
267    Ok(max_samples.min(memory_limited))
268}
269
270/// Resolve the codebook size against the complete configured sample budget,
271/// then select that final training sample directly. In particular, callers no
272/// longer collect a larger block-correlated sample and stride-thin it later.
273fn final_training_sample_count(
274    config: &IvfFieldConfig,
275    corpus_count: usize,
276    sample_limit: usize,
277) -> Result<usize> {
278    let available = corpus_count.min(sample_limit);
279    if available == 0 {
280        return Ok(0);
281    }
282    let clusters = effective_field_num_clusters(config, corpus_count, available)?;
283    Ok(available.min(clusters.saturating_mul(COARSE_TRAINING_POINTS_PER_CENTROID)))
284}
285
286/// Uniform point sample without replacement, sorted only after selection so
287/// storage reads remain monotonic. `rand::seq::index::sample` uses bounded
288/// memory proportional to the selected set rather than materializing a corpus
289/// permutation.
290fn deterministic_sample_ordinals(total: usize, take: usize, seed: u64) -> Vec<usize> {
291    debug_assert!(take <= total);
292    if take == 0 {
293        return Vec::new();
294    }
295    if take == total {
296        return (0..total).collect();
297    }
298    let mut rng = <rand::rngs::StdRng as rand::SeedableRng>::seed_from_u64(seed);
299    let mut ordinals = rand::seq::index::sample(&mut rng, total, take).into_vec();
300    ordinals.sort_unstable();
301    ordinals
302}
303
304fn validation_sample_count(
305    sample_count: usize,
306    num_clusters: usize,
307    values_per_vector: usize,
308) -> usize {
309    if sample_count <= num_clusters {
310        return 0;
311    }
312    let quality_floor = num_clusters.saturating_mul(MIN_TRAINING_POINTS_PER_CENTROID);
313    let minimum_training = if sample_count >= quality_floor {
314        quality_floor
315    } else {
316        num_clusters
317    };
318    let exact_scan_limit = MAX_VALIDATION_COORDINATE_WORK
319        .checked_div(num_clusters.saturating_mul(values_per_vector).max(1))
320        .unwrap_or(0)
321        .max(1);
322    sample_count
323        .div_ceil(VALIDATION_SAMPLE_DENOMINATOR)
324        .clamp(1, MAX_VALIDATION_SAMPLES)
325        .min(exact_scan_limit)
326        .min(sample_count - minimum_training)
327}
328
329fn model_selection_seeds(
330    training_count: usize,
331    num_clusters: usize,
332    dim: usize,
333    has_validation: bool,
334) -> &'static [u64] {
335    if !has_validation {
336        return &MODEL_SELECTION_SEEDS[..1];
337    }
338    let distance_passes = crate::structures::vector::estimated_euclidean_kmeans_distance_multiplier(
339        training_count,
340        num_clusters,
341        25,
342    );
343    let work = training_count
344        .saturating_mul(num_clusters)
345        .saturating_mul(dim)
346        .saturating_mul(distance_passes)
347        .saturating_mul(MODEL_SELECTION_SEEDS.len());
348    if work <= MAX_MULTI_SEED_COORDINATE_WORK {
349        &MODEL_SELECTION_SEEDS
350    } else {
351        &MODEL_SELECTION_SEEDS[..1]
352    }
353}
354
355fn percentile_index(len: usize, percentile: usize) -> usize {
356    debug_assert!(len > 0 && percentile <= 100);
357    (len - 1).saturating_mul(percentile).div_ceil(100)
358}
359
360fn occupancy_quality(mut counts: Vec<usize>, observations: usize) -> OccupancyQuality {
361    if counts.is_empty() {
362        return OccupancyQuality {
363            p95: 0,
364            p99: 0,
365            max: 0,
366            empty: 0,
367            penalty: 0.0,
368        };
369    }
370    counts.sort_unstable();
371    let p95 = counts[percentile_index(counts.len(), 95)];
372    let p99 = counts[percentile_index(counts.len(), 99)];
373    let max = counts.last().copied().unwrap_or(0);
374    let empty = counts.partition_point(|&count| count == 0);
375    let expected = observations as f64 / counts.len() as f64;
376    let denominator = expected.max(1.0);
377    let p99_excess = (p99 as f64 / denominator - 1.0).max(0.0);
378    let max_excess = (max as f64 / denominator - 1.0).max(0.0);
379    let empty_fraction = empty as f64 / counts.len() as f64;
380    // Distortion remains the dominant selection signal. These terms only
381    // reject seeds with materially worse posting-list tails at similar error.
382    let penalty = 0.02 * p99_excess + 0.005 * max_excess + 0.05 * empty_fraction;
383    OccupancyQuality {
384        p95,
385        p99,
386        max,
387        empty,
388        penalty,
389    }
390}
391
392fn float_model_selection_objective(
393    mean_exact_distortion: f64,
394    mean_routed_distortion: f64,
395    occupancy_penalty: f64,
396) -> f64 {
397    let scale = mean_exact_distortion.max(f64::from(f32::EPSILON));
398    let routed_distortion_excess = (mean_routed_distortion - mean_exact_distortion).max(0.0);
399    mean_exact_distortion + scale * occupancy_penalty + routed_distortion_excess
400}
401
402/// Move a deterministic uniform holdout into the matrix suffix and return the
403/// element offset separating training and validation rows. Row swaps avoid a
404/// second vector allocation and keep the original sample capacity available to
405/// the trainer.
406fn partition_contiguous_holdout_suffix<T>(
407    values: &mut [T],
408    values_per_vector: usize,
409    validation_count: usize,
410    seed: u64,
411) -> usize {
412    assert!(values_per_vector > 0);
413    assert_eq!(values.len() % values_per_vector, 0);
414    let sample_count = values.len() / values_per_vector;
415    assert!(validation_count <= sample_count);
416    if validation_count == 0 {
417        return values.len();
418    }
419    let validation_indices =
420        deterministic_sample_ordinals(sample_count, validation_count, seed ^ 0x5641_4c49_4441_5445);
421    let split_row = sample_count - validation_count;
422    let prefix_validation_count = validation_indices.partition_point(|&index| index < split_row);
423    let mut right = sample_count;
424    for &left in &validation_indices[..prefix_validation_count] {
425        loop {
426            right -= 1;
427            if validation_indices.binary_search(&right).is_err() {
428                break;
429            }
430        }
431        debug_assert!(right >= split_row);
432        for component in 0..values_per_vector {
433            values.swap(
434                left * values_per_vector + component,
435                right * values_per_vector + component,
436            );
437        }
438    }
439    split_row * values_per_vector
440}
441
442fn evaluate_float_build_quality(
443    centroids: &crate::structures::CoarseCentroids,
444    validation: &[f32],
445    routing: crate::dsl::IvfRoutingMode,
446) -> Option<FloatBuildQuality> {
447    let dim = centroids.dim;
448    let validation_count = validation.len() / dim;
449    if validation_count == 0 {
450        return None;
451    }
452    let mut occupancy = vec![0usize; centroids.num_clusters as usize];
453    let mut residual_scales = Vec::with_capacity(validation_count);
454    let mut exact_distortion_sum = 0.0f64;
455    let mut routed_distortion_sum = 0.0f64;
456    let mut router_hits = 0usize;
457    let mut construction_assignments = 0usize;
458    let effective_routing = crate::structures::vector::ivf::routing::effective_routing_mode(
459        routing,
460        centroids.num_clusters as usize,
461    );
462    let exact_routing = effective_routing == crate::dsl::IvfRoutingMode::Flat;
463    for vector in validation.chunks_exact(dim) {
464        let (exact_cluster_id, routed_cluster_id) = if exact_routing {
465            if centroids.soar_config.is_some() {
466                // Flat SOAR already performs the exact all-centroid pass needed
467                // for its primary and secondary assignments. Its primary is
468                // therefore both the exact and query-routed nearest centroid.
469                let construction_assignment = centroids.assign_with_routing(vector, routing);
470                let exact_cluster_id = construction_assignment.primary_cluster;
471                for cluster_id in construction_assignment.all_clusters() {
472                    occupancy[cluster_id as usize] += 1;
473                    construction_assignments += 1;
474                }
475                (exact_cluster_id, exact_cluster_id)
476            } else {
477                // With neither an approximate router nor SOAR, one exact pass
478                // supplies exact quality, query routing, and construction
479                // occupancy.
480                let exact_cluster_id = centroids.find_nearest(vector);
481                occupancy[exact_cluster_id as usize] += 1;
482                construction_assignments += 1;
483                (exact_cluster_id, exact_cluster_id)
484            }
485        } else {
486            let exact_cluster_id = centroids.find_nearest(vector);
487            let routed_cluster_id = centroids.probe(vector, 1, routing).cluster_ids[0];
488            let construction_assignment = centroids.assign_with_routing(vector, routing);
489            for cluster_id in construction_assignment.all_clusters() {
490                occupancy[cluster_id as usize] += 1;
491                construction_assignments += 1;
492            }
493            (exact_cluster_id, routed_cluster_id)
494        };
495        router_hits += usize::from(routed_cluster_id == exact_cluster_id);
496        let exact_distance = vector
497            .iter()
498            .zip(centroids.get_centroid(exact_cluster_id))
499            .map(|(&value, &center)| {
500                let delta = value - center;
501                delta * delta
502            })
503            .sum::<f32>();
504        let routed_distance = if routed_cluster_id == exact_cluster_id {
505            exact_distance
506        } else {
507            vector
508                .iter()
509                .zip(centroids.get_centroid(routed_cluster_id))
510                .map(|(&value, &center)| {
511                    let delta = value - center;
512                    delta * delta
513                })
514                .sum::<f32>()
515        };
516        exact_distortion_sum += f64::from(exact_distance);
517        routed_distortion_sum += f64::from(routed_distance);
518        residual_scales.push(exact_distance.max(0.0).sqrt());
519    }
520    residual_scales.sort_unstable_by(f32::total_cmp);
521    let occupancy = occupancy_quality(occupancy, construction_assignments);
522    let mean_exact_distortion = exact_distortion_sum / validation_count as f64;
523    let mean_routed_distortion = routed_distortion_sum / validation_count as f64;
524    let router_recall_at_1 = router_hits as f64 / validation_count as f64;
525    let mean_construction_assignments = construction_assignments as f64 / validation_count as f64;
526    // Keep exact codebook distortion as the primary signal. Price approximate
527    // routing by its measured excess distortion rather than treating all
528    // misses equally; retain recall@1 as a separately reported diagnostic.
529    let objective = float_model_selection_objective(
530        mean_exact_distortion,
531        mean_routed_distortion,
532        occupancy.penalty,
533    );
534    Some(FloatBuildQuality {
535        objective,
536        mean_exact_distortion,
537        mean_routed_distortion,
538        router_recall_at_1,
539        mean_construction_assignments,
540        residual_p50: residual_scales[percentile_index(validation_count, 50)],
541        residual_p95: residual_scales[percentile_index(validation_count, 95)],
542        residual_p99: residual_scales[percentile_index(validation_count, 99)],
543        occupancy,
544    })
545}
546
547/// Validate the configured centroid count and cap it to the training sample.
548///
549/// Corpus size drives the automatic heuristic, but training cannot produce
550/// more distinct centroids than the number of sampled vectors. Keeping this
551/// decision here avoids relying on a panic-prone, implicit clamp inside the
552/// trainer and gives callers a schema error for invalid explicit values.
553#[cfg(test)]
554fn effective_ivf_num_clusters(
555    config: &DenseVectorConfig,
556    corpus_count: usize,
557    sample_count: usize,
558) -> Result<usize> {
559    if sample_count == 0 {
560        return Err(Error::Schema(
561            "cannot train an IVF vector index without sample vectors".to_string(),
562        ));
563    }
564
565    effective_field_num_clusters(
566        &IvfFieldConfig::Float(config.clone()),
567        corpus_count,
568        sample_count,
569    )
570}
571
572impl<D: DirectoryWriter + 'static> IndexWriter<D> {
573    /// Train vector index from accumulated Flat vectors (manual, not auto-triggered).
574    ///
575    /// 1. Acquires a stable segment snapshot.
576    /// 2. Trains missing coarse-centroid generations.
577    /// 3. Stages ANN replacements for every affected segment.
578    /// 4. Publishes the complete segment/codebook generation atomically.
579    pub async fn build_vector_index(&self) -> Result<()> {
580        self.build_vector_generation(VectorGenerationMode::BuildMissing)
581            .await
582    }
583
584    /// Train a fresh global codebook from the current corpus and rebuild every
585    /// ANN segment into that generation. The replacement is atomic for search
586    /// readers: the old segment/codebook pair remains live until all new files
587    /// have been staged and durably committed together.
588    pub async fn retrain_vector_index(&self) -> Result<()> {
589        self.build_vector_generation(VectorGenerationMode::RetrainAll)
590            .await
591    }
592
593    async fn build_vector_generation(&self, mode: VectorGenerationMode) -> Result<()> {
594        let dense_fields = self.get_ivf_vector_fields();
595        if dense_fields.is_empty() {
596            log::info!("No dense vector fields configured for ANN indexing");
597            return Ok(());
598        }
599
600        let artifact_update = self.segment_manager.begin_vector_artifact_update().await?;
601        self.cleanup_unreferenced_vector_artifacts().await;
602
603        let fields_to_train = match mode {
604            VectorGenerationMode::BuildMissing => self.get_fields_to_build(&dense_fields).await,
605            VectorGenerationMode::RetrainAll => dense_fields.clone(),
606        };
607        for (_, config) in &fields_to_train {
608            validate_explicit_cluster_count(config.num_clusters())?;
609        }
610
611        let snapshot = self.segment_manager.acquire_snapshot().await;
612        if snapshot.is_empty() {
613            if mode == VectorGenerationMode::RetrainAll {
614                return Err(Error::Schema(
615                    "cannot retrain vector centroids without committed segments".into(),
616                ));
617            }
618            return Ok(());
619        }
620
621        let mut candidate_metadata = self.segment_manager.read_metadata(Clone::clone).await;
622        if !fields_to_train.is_empty() {
623            let total_vectors = self
624                .count_vectors_for_training(
625                    snapshot.segment_ids(),
626                    &fields_to_train,
627                    mode == VectorGenerationMode::BuildMissing,
628                )
629                .await?;
630            let artifact_generation = SegmentId::new().to_hex();
631            let updates = self
632                .train_fields(
633                    snapshot.segment_ids(),
634                    &fields_to_train,
635                    &total_vectors,
636                    &artifact_generation,
637                )
638                .await?;
639            for update in &updates {
640                candidate_metadata.init_field(update.field_id, update.index_type);
641                candidate_metadata.mark_field_built(
642                    update.field_id,
643                    update.vector_count,
644                    update.num_clusters,
645                    update.centroids_file.clone(),
646                    update.codebook_file.clone(),
647                );
648            }
649        }
650
651        let target_field_ids = dense_fields
652            .iter()
653            .filter_map(|(field, _)| {
654                candidate_metadata
655                    .is_field_built(field.0)
656                    .then_some(field.0)
657            })
658            .collect::<Vec<_>>();
659        if target_field_ids.is_empty() {
660            return Ok(());
661        }
662
663        let candidate_trained = super::IndexMetadata::try_load_trained_from_fields(
664            &candidate_metadata.vector_fields,
665            self.schema.as_ref(),
666            self.directory.as_ref(),
667        )
668        .await?
669        .map(Arc::new)
670        .ok_or_else(|| Error::Internal("candidate vector generation has no artifacts".into()))?;
671
672        let staged = self
673            .segment_manager
674            .stage_vector_generation(
675                &artifact_update,
676                snapshot.segment_ids(),
677                &target_field_ids,
678                Arc::clone(&candidate_trained),
679                mode == VectorGenerationMode::RetrainAll,
680            )
681            .await?;
682        self.segment_manager
683            .publish_vector_generation(
684                &artifact_update,
685                candidate_metadata.vector_fields,
686                candidate_trained,
687                staged,
688            )
689            .await?;
690
691        // Old readers retain the old snapshot and deserialized codebook. Once
692        // this local training snapshot drops, retired source files can be
693        // reclaimed. Reopening producers after the lease sees only the new set.
694        drop(snapshot);
695        drop(artifact_update);
696
697        // A producer that started while training was gated writes flat data.
698        // Catch already committed outputs; later commits carry their own
699        // targeted upgrade marker in PreparedSegment.
700        self.segment_manager
701            .rewrite_vector_segments(&target_field_ids)
702            .await?;
703        self.cleanup_unreferenced_vector_artifacts().await;
704        log::info!(
705            "Dense vector ANN generation {:?} complete for {} field(s)",
706            mode,
707            target_field_ids.len(),
708        );
709        Ok(())
710    }
711
712    async fn train_fields(
713        &self,
714        segment_ids: &[String],
715        fields: &[(Field, IvfFieldConfig)],
716        total_vectors: &FxHashMap<u32, usize>,
717        artifact_generation: &str,
718    ) -> Result<Vec<TrainedFieldUpdate>> {
719        let training_pool = self.segment_manager.background_cpu_pool();
720        let mut missing = Vec::new();
721        let mut updates = Vec::with_capacity(fields.len());
722        for (field, config) in fields {
723            // Sample collection and training are both field-serial. At most
724            // one bounded sample, one field's clustering scratch, and one
725            // generated artifact set can coexist.
726            let corpus_count = total_vectors.get(&field.0).copied().unwrap_or(0);
727            let Some(mut sample) = self
728                .collect_training_sample(segment_ids, *field, config, corpus_count)
729                .await?
730            else {
731                missing.push(field.0);
732                continue;
733            };
734            let model = crate::segment::block_in_place_if_multithread(|| {
735                training_pool.install(|| {
736                    Self::train_field_model(
737                        *field,
738                        config,
739                        &mut sample,
740                        corpus_count,
741                        artifact_generation,
742                    )
743                })
744            })?;
745            // Training artifacts own everything needed for persistence. Drop
746            // the potentially multi-gigabyte sample before async file I/O.
747            drop(sample);
748            updates.push(self.save_trained_field(model).await?);
749        }
750        if updates.is_empty() && !fields.is_empty() {
751            return Err(Error::Schema(format!(
752                "cannot train vector centroids: no committed vectors for field(s) {missing:?}"
753            )));
754        }
755        if !missing.is_empty() {
756            log::info!(
757                "Skipping dense vector field(s) {missing:?}: the current corpus contains no vectors"
758            );
759        }
760        Ok(updates)
761    }
762
763    /// Remove abandoned generation-qualified artifacts from cancelled or
764    /// crash-interrupted attempts. The metadata references are the complete
765    /// live set, and the exclusive update lease prevents another trainer from
766    /// creating a candidate concurrently with this sweep.
767    async fn cleanup_unreferenced_vector_artifacts(&self) {
768        let referenced = self
769            .segment_manager
770            .read_metadata(|metadata| {
771                metadata
772                    .vector_fields
773                    .values()
774                    .flat_map(|field| {
775                        field
776                            .centroids_file
777                            .iter()
778                            .chain(field.codebook_file.iter())
779                    })
780                    .cloned()
781                    .collect::<std::collections::HashSet<_>>()
782            })
783            .await;
784        let files = match self.directory.list_files(std::path::Path::new("")).await {
785            Ok(files) => files,
786            Err(error) => {
787                log::warn!("[trained] failed listing abandoned dense vector artifacts: {error}");
788                return;
789            }
790        };
791        for path in files {
792            let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
793                continue;
794            };
795            if !name.starts_with(VECTOR_ARTIFACT_PREFIX)
796                || referenced.contains(path.to_string_lossy().as_ref())
797            {
798                continue;
799            }
800            if let Err(error) = self.directory.delete(&path).await
801                && error.kind() != std::io::ErrorKind::NotFound
802            {
803                log::warn!("[trained] failed deleting abandoned artifact {path:?}: {error}");
804            }
805        }
806    }
807
808    // ========================================================================
809    // Helper methods
810    // ========================================================================
811
812    fn reject_ann_fields(ann_fields: &[u32], id_str: &str, field_ids: &[u32]) -> Result<()> {
813        for &field_id in field_ids {
814            if ann_fields.binary_search(&field_id).is_ok() {
815                return Err(Error::Schema(format!(
816                    "metadata-flat field {field_id} already has ANN data in segment {id_str}; \
817                     recreate the index instead of mixing vector generations"
818                )));
819            }
820        }
821        Ok(())
822    }
823
824    /// Open only selected flat-vector fields plus the tiny segment metadata.
825    /// Training does not need term dictionaries, stores, sparse structures, or
826    /// corpus-sized ANN run columns, and must not pin those transient readers.
827    async fn load_training_vectors(
828        &self,
829        segment_id: SegmentId,
830        field_ids: &[u32],
831    ) -> Result<crate::segment::reader::loader::VectorsFileData> {
832        let files = SegmentFiles::new(segment_id.0);
833        let meta_bytes = self
834            .directory
835            .open_read(&files.meta)
836            .await?
837            .read_bytes()
838            .await?;
839        let meta = SegmentMeta::deserialize(meta_bytes.as_slice())?;
840        if meta.id != segment_id.0 {
841            return Err(Error::Corruption(format!(
842                "segment metadata ID {:032x} does not match file ID {}",
843                meta.id,
844                segment_id.to_hex(),
845            )));
846        }
847        crate::segment::reader::loader::load_flat_vectors_file(
848            self.directory.as_ref(),
849            &files,
850            self.schema.as_ref(),
851            meta.num_docs,
852            field_ids,
853        )
854        .await
855    }
856
857    /// Get all dense vector fields that need ANN indexes
858    fn get_ivf_vector_fields(&self) -> Vec<(Field, IvfFieldConfig)> {
859        self.schema
860            .fields()
861            .filter_map(|(field, entry)| {
862                if entry.field_type == FieldType::DenseVector && entry.indexed {
863                    entry
864                        .dense_vector_config
865                        .as_ref()
866                        // Flat is a pre-build storage state; the production ANN
867                        // path is trained once and shared by every segment.
868                        .filter(|c| c.uses_ivf())
869                        .map(|c| (field, IvfFieldConfig::Float(c.clone())))
870                } else if entry.field_type == FieldType::BinaryDenseVector && entry.indexed {
871                    entry
872                        .binary_dense_vector_config
873                        .as_ref()
874                        .filter(|config| config.index_type == BinaryIndexType::Ivf)
875                        .map(|config| (field, IvfFieldConfig::Binary(config.clone())))
876                } else {
877                    None
878                }
879            })
880            .collect()
881    }
882
883    /// Get fields that need building (not already built)
884    async fn get_fields_to_build(
885        &self,
886        dense_fields: &[(Field, IvfFieldConfig)],
887    ) -> Vec<(Field, IvfFieldConfig)> {
888        let field_ids: Vec<u32> = dense_fields.iter().map(|(f, _)| f.0).collect();
889        let built: Vec<u32> = self
890            .segment_manager
891            .read_metadata(|meta| {
892                field_ids
893                    .iter()
894                    .filter(|fid| meta.is_field_built(**fid))
895                    .copied()
896                    .collect()
897            })
898            .await;
899        dense_fields
900            .iter()
901            .filter(|(field, _)| !built.contains(&field.0))
902            .cloned()
903            .collect()
904    }
905
906    /// Count every configured field without reading any vector payload bytes.
907    async fn count_vectors_for_training(
908        &self,
909        segment_ids: &[String],
910        fields_to_build: &[(Field, IvfFieldConfig)],
911        require_flat_generation: bool,
912    ) -> Result<FxHashMap<u32, usize>> {
913        let mut total_vectors: FxHashMap<u32, usize> = FxHashMap::default();
914        let field_ids: Vec<u32> = fields_to_build.iter().map(|(field, _)| field.0).collect();
915
916        // Initial construction rejects
917        // ANN payloads for metadata-flat fields; an explicit retrain reads the
918        // exact flat vectors retained beside the current ANN generation.
919        for id_str in segment_ids {
920            let segment_id = SegmentId::from_hex(id_str)
921                .ok_or_else(|| Error::Corruption(format!("Invalid segment ID: {}", id_str)))?;
922            let vectors = self.load_training_vectors(segment_id, &field_ids).await?;
923
924            if require_flat_generation {
925                Self::reject_ann_fields(&vectors.ann_fields, id_str, &field_ids)?;
926            }
927
928            for (field, _) in fields_to_build {
929                if let Some(flat) = vectors.flat_vectors.get(&field.0) {
930                    let total = total_vectors.entry(field.0).or_default();
931                    *total = total.checked_add(flat.num_vectors).ok_or_else(|| {
932                        Error::Corruption(format!(
933                            "vector count overflows usize for field {}",
934                            field.0,
935                        ))
936                    })?;
937                }
938            }
939        }
940        Ok(total_vectors)
941    }
942
943    /// Fetch one deterministic, uniform field sample from the pinned segment
944    /// snapshot. Only selected ranges are read; all other corpus vectors stay
945    /// on disk. The caller trains and drops this sample before moving to the
946    /// next field.
947    async fn collect_training_sample(
948        &self,
949        segment_ids: &[String],
950        field: Field,
951        config: &IvfFieldConfig,
952        total: usize,
953    ) -> Result<Option<TrainingSample>> {
954        if total == 0 {
955            return Ok(None);
956        }
957        let bytes_per_sample = match config {
958            IvfFieldConfig::Float(config) => config
959                .dim
960                .checked_mul(size_of::<f32>())
961                .ok_or_else(|| Error::Schema("float training vector size overflows".into()))?,
962            IvfFieldConfig::Binary(config) => config.dim.div_ceil(8),
963        };
964        let limit = training_sample_limit(
965            self.config.vector_training_max_samples,
966            self.config.vector_training_memory_bytes,
967            bytes_per_sample,
968        )?;
969        let take = final_training_sample_count(config, total, limit)?;
970        let sample_seed = 0x4845_524d_4553_4956 ^ field.0 as u64 ^ total as u64;
971        let ordinals = deterministic_sample_ordinals(total, take, sample_seed);
972
973        let mut sample = match config {
974            IvfFieldConfig::Float(config) => TrainingSample::Float(Vec::with_capacity(
975                take.checked_mul(config.dim)
976                    .ok_or_else(|| Error::Schema("float training sample size overflows".into()))?,
977            )),
978            IvfFieldConfig::Binary(_) => TrainingSample::Binary(Vec::with_capacity(
979                take.checked_mul(bytes_per_sample)
980                    .ok_or_else(|| Error::Schema("binary training sample size overflows".into()))?,
981            )),
982        };
983        let max_read_vectors = (MAX_SAMPLE_READ_BYTES / bytes_per_sample.max(1)).max(1);
984        let mut global_offset = 0usize;
985        let mut cursor = 0usize;
986        let field_ids = [field.0];
987
988        for id_str in segment_ids {
989            let segment_id = SegmentId::from_hex(id_str)
990                .ok_or_else(|| Error::Corruption(format!("Invalid segment ID: {id_str}")))?;
991            let vectors = self.load_training_vectors(segment_id, &field_ids).await?;
992
993            let Some(lazy_flat) = vectors.flat_vectors.get(&field.0) else {
994                continue;
995            };
996            let base = global_offset;
997            let end = base.checked_add(lazy_flat.num_vectors).ok_or_else(|| {
998                Error::Corruption(format!("vector offset overflows for field {}", field.0))
999            })?;
1000            global_offset = end;
1001            let first = cursor;
1002            while cursor < ordinals.len() && ordinals[cursor] < end {
1003                cursor += 1;
1004            }
1005            let selected = &ordinals[first..cursor];
1006            let mut run_start = 0;
1007            while run_start < selected.len() {
1008                let mut run_end = run_start + 1;
1009                while run_end < selected.len() {
1010                    let selected_count = run_end - run_start + 1;
1011                    let span = selected[run_end] - selected[run_start] + 1;
1012                    if span > max_read_vectors
1013                        || span > selected_count.saturating_mul(MAX_SAMPLE_READ_AMPLIFICATION)
1014                    {
1015                        break;
1016                    }
1017                    run_end += 1;
1018                }
1019                let local_start = selected[run_start] - base;
1020                let read_len = selected[run_end - 1] - selected[run_start] + 1;
1021                let bytes = lazy_flat
1022                    .read_vectors_batch(local_start, read_len)
1023                    .await
1024                    .map_err(crate::Error::Io)?;
1025                match &mut sample {
1026                    TrainingSample::Binary(codes) => {
1027                        let expected = read_len.checked_mul(bytes_per_sample).ok_or_else(|| {
1028                            Error::Corruption("binary sample read size overflows".into())
1029                        })?;
1030                        if bytes.len() != expected {
1031                            return Err(Error::Corruption(format!(
1032                                "binary sample read returned {} bytes, expected {expected}",
1033                                bytes.len(),
1034                            )));
1035                        }
1036                        for &ordinal in &selected[run_start..run_end] {
1037                            let relative = ordinal - selected[run_start];
1038                            let offset = relative * bytes_per_sample;
1039                            codes.extend_from_slice(
1040                                &bytes.as_slice()[offset..offset + bytes_per_sample],
1041                            );
1042                        }
1043                    }
1044                    TrainingSample::Float(values) => {
1045                        let dim = lazy_flat.dim;
1046                        let float_count = read_len.checked_mul(dim).ok_or_else(|| {
1047                            Error::Corruption("float sample read size overflows".into())
1048                        })?;
1049                        let mut decoded = vec![0.0; float_count];
1050                        crate::segment::dequantize_raw(
1051                            bytes.as_slice(),
1052                            lazy_flat.quantization,
1053                            decoded.len(),
1054                            &mut decoded,
1055                        )
1056                        .map_err(crate::Error::Io)?;
1057                        for &ordinal in &selected[run_start..run_end] {
1058                            let relative = ordinal - selected[run_start];
1059                            let offset = relative * dim;
1060                            values.extend_from_slice(&decoded[offset..offset + dim]);
1061                        }
1062                    }
1063                }
1064                run_start = run_end;
1065            }
1066        }
1067
1068        let collected = sample.len(config.dim());
1069        if global_offset != total || cursor != take || collected != take {
1070            return Err(Error::Corruption(format!(
1071                "training sample coverage mismatch for field {}: counted={total}, traversed={global_offset}, selected={cursor}, collected={collected}",
1072                field.0,
1073            )));
1074        }
1075        if collected < total {
1076            log::info!(
1077                "Sampled {} / {} dense vectors for field {} (max {} vectors / {} resident)",
1078                collected,
1079                total,
1080                field.0,
1081                self.config.vector_training_max_samples,
1082                crate::format_bytes(self.config.vector_training_memory_bytes as u64),
1083            );
1084        }
1085        Ok(Some(sample))
1086    }
1087
1088    /// Train one field. Called from the shared bounded Rayon pool, so fields
1089    /// and each field's internal clustering work compose without extra pools.
1090    fn train_field_model(
1091        field: Field,
1092        config: &IvfFieldConfig,
1093        sample: &mut TrainingSample,
1094        corpus_count: usize,
1095        artifact_generation: &str,
1096    ) -> Result<TrainedFieldModel> {
1097        let field_id = field.0;
1098        let dim = config.dim();
1099        let sample_count = sample.len(dim);
1100        if sample_count == 0 || corpus_count == 0 {
1101            return Err(Error::Internal(format!(
1102                "empty training sample for non-empty field {field_id}"
1103            )));
1104        }
1105        let num_clusters = effective_field_num_clusters(config, corpus_count, sample_count)?;
1106
1107        log::info!(
1108            "Training dense vector index for field {} with {} sampled / {} total vectors, {} clusters (dim={})",
1109            field_id,
1110            sample_count,
1111            corpus_count,
1112            num_clusters,
1113            dim,
1114        );
1115
1116        let centroids_filename =
1117            format!("{VECTOR_ARTIFACT_PREFIX}{artifact_generation}_field_{field_id}_centroids.bin");
1118
1119        let artifacts = match (config, sample) {
1120            (IvfFieldConfig::Float(config), TrainingSample::Float(values))
1121                if config.index_type == VectorIndexType::IvfTq =>
1122            {
1123                values
1124                    .chunks_exact_mut(dim)
1125                    .for_each(crate::structures::vector::ivf::routing::normalize_cosine_in_place);
1126                let candidate_validation_count =
1127                    validation_sample_count(sample_count, num_clusters, dim);
1128                let candidate_training_count = sample_count - candidate_validation_count;
1129                let seeds = model_selection_seeds(
1130                    candidate_training_count,
1131                    num_clusters,
1132                    dim,
1133                    candidate_validation_count > 0,
1134                );
1135                let split_seed =
1136                    MODEL_SELECTION_SEEDS[0] ^ u64::from(field_id) ^ corpus_count as u64;
1137                let split = if seeds.len() > 1 {
1138                    partition_contiguous_holdout_suffix(
1139                        values.as_mut_slice(),
1140                        dim,
1141                        candidate_validation_count,
1142                        split_seed,
1143                    )
1144                } else {
1145                    values.len()
1146                };
1147                let (training_values, validation) = values.as_slice().split_at(split);
1148                let training_count = training_values.len() / dim;
1149                let validation_count = validation.len() / dim;
1150                if seeds.len() > 1 {
1151                    log::info!(
1152                        "Field {field_id}: {} training + {} held-out vectors; evaluating {} deterministic centroid seed(s)",
1153                        training_count,
1154                        validation_count,
1155                        seeds.len(),
1156                    );
1157                } else {
1158                    log::info!(
1159                        "Field {field_id}: training all {} sampled vectors with one deterministic \
1160                         centroid seed; model-selection holdout disabled",
1161                        training_count,
1162                    );
1163                }
1164
1165                let mut base_config = crate::structures::CoarseConfig::new(dim, num_clusters)
1166                    .with_routing(config.ivf_routing);
1167                if let Some(soar) = config.soar.clone() {
1168                    base_config = base_config.with_soar(soar);
1169                }
1170
1171                let mut selected: Option<(
1172                    crate::structures::CoarseCentroids,
1173                    Option<FloatBuildQuality>,
1174                    u64,
1175                )> = None;
1176                for &seed in seeds {
1177                    let candidate = crate::structures::CoarseCentroids::train_contiguous(
1178                        &base_config.clone().with_seed(seed),
1179                        training_values,
1180                        training_count,
1181                    );
1182                    let quality =
1183                        evaluate_float_build_quality(&candidate, validation, config.ivf_routing);
1184                    if let Some(quality) = quality {
1185                        log::info!(
1186                            "Field {field_id} IVF candidate seed={seed}: objective={:.6}, \
1187                             exact/routed_mean_distortion={:.6}/{:.6}, router_recall@1={:.4}, \
1188                             construction_postings/vector={:.3}, \
1189                             residual_scale[p50/p95/p99]={:.4}/{:.4}/{:.4}, \
1190                             construction_occupancy[p95/p99/max/empty]={}/{}/{}/{}",
1191                            quality.objective,
1192                            quality.mean_exact_distortion,
1193                            quality.mean_routed_distortion,
1194                            quality.router_recall_at_1,
1195                            quality.mean_construction_assignments,
1196                            quality.residual_p50,
1197                            quality.residual_p95,
1198                            quality.residual_p99,
1199                            quality.occupancy.p95,
1200                            quality.occupancy.p99,
1201                            quality.occupancy.max,
1202                            quality.occupancy.empty,
1203                        );
1204                    }
1205                    let replace = selected.as_ref().is_none_or(|(_, best, _)| {
1206                        quality
1207                            .map(|quality| quality.objective)
1208                            .unwrap_or(f64::INFINITY)
1209                            .total_cmp(
1210                                &best
1211                                    .map(|quality| quality.objective)
1212                                    .unwrap_or(f64::INFINITY),
1213                            )
1214                            .is_lt()
1215                    });
1216                    if replace {
1217                        selected = Some((candidate, quality, seed));
1218                    }
1219                }
1220                let (mut centroids, quality, seed) =
1221                    selected.expect("the fixed centroid seed bank is non-empty");
1222                if let Some(quality) = quality {
1223                    log::info!(
1224                        "Field {field_id}: selected IVF seed {seed} with held-out objective {:.6} \
1225                         (occupancy penalty {:.4})",
1226                        quality.objective,
1227                        quality.occupancy.penalty,
1228                    );
1229                } else {
1230                    log::info!(
1231                        "Field {field_id}: selected IVF seed {seed} without a model-selection \
1232                         holdout",
1233                    );
1234                }
1235                centroids.version =
1236                    crate::structures::mark_ivf_tq_cosine_generation(centroids.version);
1237                TrainedFieldArtifacts::FloatCentroids(centroids)
1238            }
1239            (IvfFieldConfig::Binary(config), TrainingSample::Binary(codes)) => {
1240                let byte_len = dim.div_ceil(8);
1241                let training_count = codes.len() / byte_len;
1242                let mut binary_config = crate::structures::BinaryIvfConfig::new(dim, num_clusters);
1243                binary_config.max_train_samples = training_count;
1244                binary_config.routing = config.ivf_routing;
1245                let quantizer = crate::structures::BinaryCoarseQuantizer::train(
1246                    binary_config,
1247                    codes,
1248                    training_count,
1249                )
1250                .map_err(Error::Io)?;
1251                TrainedFieldArtifacts::Binary(quantizer)
1252            }
1253            _ => {
1254                return Err(Error::Internal(format!(
1255                    "training sample kind does not match field {field_id}"
1256                )));
1257            }
1258        };
1259
1260        let actual_num_clusters = match &artifacts {
1261            TrainedFieldArtifacts::FloatCentroids(centroids) => centroids.num_clusters as usize,
1262            TrainedFieldArtifacts::Binary(quantizer) => quantizer.num_clusters as usize,
1263        };
1264        Ok(TrainedFieldModel {
1265            update: TrainedFieldUpdate {
1266                field_id,
1267                index_type: config.index_type(),
1268                vector_count: corpus_count,
1269                num_clusters: actual_num_clusters,
1270                centroids_file: centroids_filename,
1271                codebook_file: None,
1272            },
1273            artifacts,
1274        })
1275    }
1276
1277    async fn save_trained_field(&self, model: TrainedFieldModel) -> Result<TrainedFieldUpdate> {
1278        let TrainedFieldModel { update, artifacts } = model;
1279        match artifacts {
1280            TrainedFieldArtifacts::FloatCentroids(centroids) => {
1281                self.save_trained_artifact(&centroids, &update.centroids_file)
1282                    .await?;
1283                log::info!(
1284                    "Saved IVF-TQ coarse artifact for field {} ({} clusters; leaf codec is derived)",
1285                    update.field_id,
1286                    centroids.num_clusters,
1287                );
1288            }
1289            TrainedFieldArtifacts::Binary(quantizer) => {
1290                self.save_trained_artifact(&quantizer, &update.centroids_file)
1291                    .await?;
1292                log::info!(
1293                    "Saved binary IVF artifact for field {} ({} clusters)",
1294                    update.field_id,
1295                    quantizer.num_clusters,
1296                );
1297            }
1298        }
1299        Ok(update)
1300    }
1301
1302    /// Serialize a trained structure to bincode and save to an index-level file.
1303    async fn save_trained_artifact(
1304        &self,
1305        artifact: &impl serde::Serialize,
1306        filename: &str,
1307    ) -> Result<()> {
1308        let temp_filename = format!("{filename}.tmp");
1309        let temp_path = std::path::Path::new(&temp_filename);
1310        let final_path = std::path::Path::new(filename);
1311        let mut writer = self.directory.streaming_writer(temp_path).await?;
1312        let encode_result = {
1313            let mut limited = SizeLimitedWriter::new(
1314                writer.as_mut(),
1315                super::metadata::MAX_TRAINED_ARTIFACT_BYTES,
1316            );
1317            bincode::serde::encode_into_std_write(
1318                artifact,
1319                &mut limited,
1320                bincode::config::standard(),
1321            )
1322        };
1323        if let Err(error) = encode_result {
1324            drop(writer);
1325            let _ = self.directory.delete(temp_path).await;
1326            return Err(Error::Serialization(format!(
1327                "failed to serialize trained artifact '{filename}': {error}"
1328            )));
1329        }
1330        if let Err(error) = writer.finish() {
1331            let _ = self.directory.delete(temp_path).await;
1332            return Err(Error::Io(error));
1333        }
1334        if let Err(error) = self.directory.rename(temp_path, final_path).await {
1335            let _ = self.directory.delete(temp_path).await;
1336            return Err(Error::Io(error));
1337        }
1338        self.directory.sync().await?;
1339        Ok(())
1340    }
1341}
1342
1343#[cfg(test)]
1344mod tests {
1345    use super::*;
1346
1347    fn ivf_config(num_clusters: Option<usize>) -> DenseVectorConfig {
1348        DenseVectorConfig::ivf_tq(8, num_clusters, 4)
1349    }
1350
1351    #[test]
1352    fn effective_clusters_follow_corpus_heuristic_but_fit_sample() {
1353        let config = ivf_config(None);
1354
1355        assert_eq!(
1356            effective_ivf_num_clusters(&config, 1_000_000, 73).unwrap(),
1357            16
1358        );
1359        assert_eq!(
1360            effective_ivf_num_clusters(&config, 10_000, 1_000).unwrap(),
1361            25
1362        );
1363    }
1364
1365    #[test]
1366    fn effective_clusters_clamp_explicit_value_to_sample() {
1367        let config = ivf_config(Some(256));
1368        assert_eq!(
1369            effective_ivf_num_clusters(&config, 1_000_000, 17).unwrap(),
1370            17
1371        );
1372    }
1373
1374    #[test]
1375    fn effective_clusters_reject_invalid_explicit_bounds() {
1376        let zero = effective_ivf_num_clusters(&ivf_config(Some(0)), 10_000, 100)
1377            .unwrap_err()
1378            .to_string();
1379        assert!(zero.contains("at least 1"));
1380
1381        let too_many =
1382            effective_ivf_num_clusters(&ivf_config(Some(MAX_IVF_CLUSTERS + 1)), 10_000, 100)
1383                .unwrap_err()
1384                .to_string();
1385        assert!(too_many.contains("must not exceed 1048576"));
1386    }
1387
1388    #[test]
1389    fn effective_clusters_reject_empty_training_sample() {
1390        let error = effective_ivf_num_clusters(&ivf_config(None), 10_000, 0)
1391            .unwrap_err()
1392            .to_string();
1393        assert!(error.contains("without sample vectors"));
1394    }
1395
1396    #[test]
1397    fn training_sample_limit_honors_both_cli_bounds() {
1398        assert_eq!(training_sample_limit(10_000_000, 4_096, 4).unwrap(), 1_024);
1399        assert_eq!(training_sample_limit(100, 4_096, 4).unwrap(), 100);
1400        let error = training_sample_limit(100, 3, 4).unwrap_err().to_string();
1401        assert!(error.contains("cannot hold one"), "{error}");
1402    }
1403
1404    #[test]
1405    fn final_sample_is_selected_at_the_points_per_centroid_ceiling() {
1406        let config = IvfFieldConfig::Float(DenseVectorConfig::ivf_tq(8, Some(4), 1));
1407        assert_eq!(
1408            final_training_sample_count(&config, 10_000, 10_000).unwrap(),
1409            4 * COARSE_TRAINING_POINTS_PER_CENTROID,
1410        );
1411        assert_eq!(
1412            final_training_sample_count(&config, 10_000, 512).unwrap(),
1413            512,
1414        );
1415    }
1416
1417    #[test]
1418    fn holdout_preserves_the_training_points_per_centroid_floor() {
1419        assert_eq!(validation_sample_count(390, 10, 8), 0);
1420        assert_eq!(validation_sample_count(391, 10, 8), 1);
1421
1422        let config = IvfFieldConfig::Float(ivf_config(None));
1423        let sample_count = 1_000;
1424        let clusters = effective_field_num_clusters(&config, 10_000, sample_count).unwrap();
1425        let held_out = validation_sample_count(sample_count, clusters, config.dim());
1426        assert!(
1427            sample_count - held_out >= clusters.saturating_mul(MIN_TRAINING_POINTS_PER_CENTROID)
1428        );
1429    }
1430
1431    #[test]
1432    fn deterministic_point_sample_is_sorted_unique_and_repeatable() {
1433        let first = deterministic_sample_ordinals(10_000, 1_000, 7);
1434        let repeated = deterministic_sample_ordinals(10_000, 1_000, 7);
1435        let other_seed = deterministic_sample_ordinals(10_000, 1_000, 8);
1436        assert_eq!(first, repeated);
1437        assert_ne!(first, other_seed);
1438        assert_eq!(first.len(), 1_000);
1439        assert!(first.windows(2).all(|pair| pair[0] < pair[1]));
1440        assert!(first.iter().all(|&ordinal| ordinal < 10_000));
1441    }
1442
1443    #[test]
1444    fn model_selection_accounts_for_initialization_and_all_lloyd_passes() {
1445        assert_eq!(model_selection_seeds(1_000, 16, 8, true).len(), 2);
1446        assert_eq!(model_selection_seeds(1_000, 16, 8, false).len(), 1);
1447        // A single assignment pass is only 180M coordinate comparisons, but
1448        // two complete initialization/refinement candidates exceed the budget.
1449        assert_eq!(model_selection_seeds(1_800, 1_000, 100, true).len(), 1);
1450        assert_eq!(
1451            model_selection_seeds(MAX_MULTI_SEED_COORDINATE_WORK, 2, 1, true).len(),
1452            1,
1453        );
1454    }
1455
1456    #[test]
1457    fn flat_float_sample_partitions_a_deterministic_holdout_suffix() {
1458        let original: Vec<f32> = (0..20).map(|value| value as f32).collect();
1459        let mut first = original.clone();
1460        let mut repeated = original.clone();
1461        let validation_count = validation_sample_count(10, 2, 2);
1462        let first_split = partition_contiguous_holdout_suffix(&mut first, 2, validation_count, 11);
1463        let repeated_split =
1464            partition_contiguous_holdout_suffix(&mut repeated, 2, validation_count, 11);
1465
1466        assert_eq!(first, repeated);
1467        assert_eq!(first_split, repeated_split);
1468        assert_eq!(first_split, 18);
1469        assert_eq!(first.len(), original.len());
1470        assert_eq!(first[first_split..].len(), 2);
1471
1472        let mut first_components: Vec<u32> =
1473            first.chunks_exact(2).map(|row| row[0] as u32).collect();
1474        first_components.sort_unstable();
1475        assert_eq!(first_components, vec![0, 2, 4, 6, 8, 10, 12, 14, 16, 18]);
1476        assert!(first.chunks_exact(2).all(|row| row[1] == row[0] + 1.0));
1477    }
1478
1479    #[test]
1480    fn occupancy_report_exposes_tail_and_empty_cells() {
1481        let report = occupancy_quality(vec![0, 1, 2, 7], 10);
1482        assert_eq!(report.p95, 7);
1483        assert_eq!(report.p99, 7);
1484        assert_eq!(report.max, 7);
1485        assert_eq!(report.empty, 1);
1486        assert!(report.penalty > 0.0);
1487    }
1488
1489    #[test]
1490    fn model_selection_objective_prices_routed_distortion_excess() {
1491        let objective = float_model_selection_objective(2.0, 3.0, 0.1);
1492        assert!((objective - 3.2).abs() < f64::EPSILON);
1493        assert_eq!(float_model_selection_objective(2.0, 1.5, 0.0), 2.0);
1494    }
1495
1496    #[test]
1497    fn float_quality_reports_exact_query_router_recall() {
1498        let training = [0.0, 0.0, 0.1, 0.0, 10.0, 10.0, 10.1, 10.0];
1499        let config = crate::structures::CoarseConfig::new(2, 2);
1500        let centroids = crate::structures::CoarseCentroids::train_contiguous(&config, &training, 4);
1501        for routing in [
1502            crate::dsl::IvfRoutingMode::Flat,
1503            crate::dsl::IvfRoutingMode::Auto,
1504        ] {
1505            let quality =
1506                evaluate_float_build_quality(&centroids, &[0.05, 0.0, 10.05, 10.0], routing)
1507                    .unwrap();
1508
1509            assert_eq!(quality.router_recall_at_1, 1.0);
1510            assert!(
1511                (quality.mean_exact_distortion - quality.mean_routed_distortion).abs()
1512                    < f64::EPSILON
1513            );
1514            assert_eq!(quality.mean_construction_assignments, 1.0);
1515            assert_eq!(quality.occupancy.empty, 0);
1516        }
1517    }
1518
1519    #[test]
1520    fn float_quality_counts_soar_secondary_postings_in_occupancy() {
1521        let training = [0.0, 0.0, 0.1, 0.0, 10.0, 10.0, 10.1, 10.0];
1522        let config = crate::structures::CoarseConfig::new(2, 2)
1523            .with_routing(crate::dsl::IvfRoutingMode::Flat)
1524            .with_soar(crate::structures::SoarConfig::full());
1525        let centroids = crate::structures::CoarseCentroids::train_contiguous(&config, &training, 4);
1526        let quality = evaluate_float_build_quality(
1527            &centroids,
1528            &[0.05, 0.0, 10.05, 10.0],
1529            crate::dsl::IvfRoutingMode::Flat,
1530        )
1531        .unwrap();
1532
1533        assert_eq!(quality.mean_construction_assignments, 2.0);
1534        assert_eq!(quality.occupancy.empty, 0);
1535    }
1536
1537    #[test]
1538    fn artifact_writer_enforces_limit_without_writing_past_it() {
1539        let mut output = Vec::new();
1540        let mut writer = SizeLimitedWriter::new(&mut output, 3);
1541        writer.write_all(&[1, 2]).unwrap();
1542        let error = writer.write_all(&[3, 4]).unwrap_err().to_string();
1543        assert!(error.contains("3-byte safety limit"), "{error}");
1544        assert_eq!(output, vec![1, 2]);
1545    }
1546
1547    #[test]
1548    fn ivf_tq_training_marks_generation_normalizes_and_calibrates_default_soar() {
1549        let config = IvfFieldConfig::Float(DenseVectorConfig::ivf_tq(2, Some(1), 1));
1550        let mut sample = TrainingSample::Float(vec![3.0, 4.0, 30.0, 40.0, 300.0, 400.0]);
1551        let model = IndexWriter::<crate::directories::RamDirectory>::train_field_model(
1552            Field(3),
1553            &config,
1554            &mut sample,
1555            3,
1556            "test",
1557        )
1558        .unwrap();
1559        let TrainedFieldArtifacts::FloatCentroids(centroids) = model.artifacts else {
1560            panic!("expected float IVF-TQ centroids");
1561        };
1562
1563        assert!(crate::structures::is_ivf_tq_cosine_generation(
1564            centroids.version
1565        ));
1566        assert!((centroids.centroids[0] - 0.6).abs() < 1e-6);
1567        assert!((centroids.centroids[1] - 0.8).abs() < 1e-6);
1568        let soar = centroids
1569            .soar_config
1570            .as_ref()
1571            .expect("default SOAR should propagate into the trained router");
1572        assert_eq!(soar.num_secondary, 1);
1573        assert!(soar.selective);
1574        assert!(
1575            soar.spill_threshold > 0.0,
1576            "the negative 30% target tag should be replaced by a calibrated threshold"
1577        );
1578        assert_eq!(soar.calibration_target(), None);
1579    }
1580
1581    #[test]
1582    fn explicitly_disabled_soar_stays_off_during_ivf_tq_training() {
1583        let config = IvfFieldConfig::Float(DenseVectorConfig::ivf_tq(2, Some(1), 1).without_soar());
1584        let mut sample = TrainingSample::Float(vec![3.0, 4.0, 30.0, 40.0, 300.0, 400.0]);
1585        let model = IndexWriter::<crate::directories::RamDirectory>::train_field_model(
1586            Field(3),
1587            &config,
1588            &mut sample,
1589            3,
1590            "test-no-soar",
1591        )
1592        .unwrap();
1593        let TrainedFieldArtifacts::FloatCentroids(centroids) = model.artifacts else {
1594            panic!("expected float IVF-TQ centroids");
1595        };
1596
1597        assert!(centroids.soar_config.is_none());
1598    }
1599
1600    // ===== rebuild destructive-downgrade regression tests =====
1601
1602    use std::path::Path;
1603    use std::sync::atomic::{AtomicBool, Ordering};
1604
1605    use crate::directories::{
1606        Directory, DirectoryWriter as DirectoryWriterTrait, FileHandle, RamDirectory, RangeReadFn,
1607    };
1608    use crate::dsl::{Document, SchemaBuilder};
1609    use crate::index::{IndexConfig, IndexWriter};
1610
1611    const READ_FAIL_DOCS: usize = 5;
1612    const READ_FAIL_DIM: usize = 4;
1613    /// Flat entry layout of a single-field, flat-only `.vectors` file written
1614    /// by the segment builder (data-first format): header (16 bytes) + raw f32
1615    /// vectors + doc-id map + TOC + footer. Only the raw vector region is read
1616    /// by training collection; segment open touches the header, doc-id map,
1617    /// TOC, and footer, which all live outside this byte range.
1618    const VEC_REGION_START: u64 = 16;
1619    const VEC_REGION_END: u64 = VEC_REGION_START + (READ_FAIL_DOCS * READ_FAIL_DIM * 4) as u64;
1620
1621    /// RamDirectory wrapper whose `.vectors` handles fail range reads of the
1622    /// raw vector region while `fail_vector_reads` is armed. Segment open
1623    /// keeps succeeding, so exactly the training-collection batch reads fail —
1624    /// the I/O the rebuild path used to swallow with `if let Ok`.
1625    #[derive(Clone, Default)]
1626    struct VectorReadFailDirectory {
1627        inner: RamDirectory,
1628        fail_vector_reads: Arc<AtomicBool>,
1629        fail_all_vector_reads: Arc<AtomicBool>,
1630    }
1631
1632    #[async_trait::async_trait]
1633    impl Directory for VectorReadFailDirectory {
1634        async fn exists(&self, path: &Path) -> std::io::Result<bool> {
1635            self.inner.exists(path).await
1636        }
1637
1638        async fn file_size(&self, path: &Path) -> std::io::Result<u64> {
1639            self.inner.file_size(path).await
1640        }
1641
1642        async fn open_read(&self, path: &Path) -> std::io::Result<FileHandle> {
1643            self.inner.open_read(path).await
1644        }
1645
1646        async fn read_range(
1647            &self,
1648            path: &Path,
1649            range: std::ops::Range<u64>,
1650        ) -> std::io::Result<crate::directories::OwnedBytes> {
1651            self.inner.read_range(path, range).await
1652        }
1653
1654        async fn list_files(&self, prefix: &Path) -> std::io::Result<Vec<std::path::PathBuf>> {
1655            self.inner.list_files(prefix).await
1656        }
1657
1658        async fn open_lazy(&self, path: &Path) -> std::io::Result<FileHandle> {
1659            let handle = self.inner.open_lazy(path).await?;
1660            if path.extension().is_some_and(|ext| ext == "vectors") {
1661                let armed = Arc::clone(&self.fail_vector_reads);
1662                let fail_all = Arc::clone(&self.fail_all_vector_reads);
1663                let len = handle.len();
1664                let read_fn: RangeReadFn = Arc::new(move |range: std::ops::Range<u64>| {
1665                    let handle = handle.clone();
1666                    let armed = Arc::clone(&armed);
1667                    let fail_all = Arc::clone(&fail_all);
1668                    Box::pin(async move {
1669                        if fail_all.load(Ordering::SeqCst)
1670                            || (armed.load(Ordering::SeqCst)
1671                                && range.start >= VEC_REGION_START
1672                                && range.end <= VEC_REGION_END)
1673                        {
1674                            return Err(std::io::Error::other("injected vector data read failure"));
1675                        }
1676                        handle.read_bytes_range(range).await
1677                    })
1678                });
1679                return Ok(FileHandle::lazy(len, read_fn));
1680            }
1681            Ok(handle)
1682        }
1683    }
1684
1685    #[async_trait::async_trait]
1686    impl DirectoryWriterTrait for VectorReadFailDirectory {
1687        async fn write(&self, path: &Path, data: &[u8]) -> std::io::Result<()> {
1688            self.inner.write(path, data).await
1689        }
1690
1691        async fn delete(&self, path: &Path) -> std::io::Result<()> {
1692            self.inner.delete(path).await
1693        }
1694
1695        async fn rename(&self, from: &Path, to: &Path) -> std::io::Result<()> {
1696            self.inner.rename(from, to).await
1697        }
1698
1699        async fn sync(&self) -> std::io::Result<()> {
1700            self.inner.sync().await
1701        }
1702
1703        async fn streaming_writer(
1704            &self,
1705            path: &Path,
1706        ) -> std::io::Result<Box<dyn crate::directories::StreamingWriter>> {
1707            self.inner.streaming_writer(path).await
1708        }
1709    }
1710
1711    /// A failed read from the flat staging generation must abort training
1712    /// before artifacts or Built metadata are published.
1713    #[tokio::test]
1714    async fn build_propagates_vector_read_errors_without_publishing_artifacts() {
1715        let mut sb = SchemaBuilder::default();
1716        let embedding = sb.add_dense_vector_field_with_config(
1717            "embedding",
1718            true,
1719            true,
1720            DenseVectorConfig::ivf_tq(READ_FAIL_DIM, Some(1), 1),
1721        );
1722        let schema = sb.build();
1723
1724        let dir = VectorReadFailDirectory::default();
1725        let config = IndexConfig {
1726            merge_policy: Box::new(crate::merge::NoMergePolicy),
1727            num_indexing_threads: 1,
1728            ..Default::default()
1729        };
1730        let mut writer = IndexWriter::create(dir.clone(), schema, config)
1731            .await
1732            .unwrap();
1733        for i in 0..READ_FAIL_DOCS {
1734            let mut doc = Document::new();
1735            doc.add_dense_vector(embedding, vec![i as f32 + 1.0; READ_FAIL_DIM]);
1736            writer.add_document(doc).unwrap();
1737        }
1738        writer.commit().await.unwrap();
1739        dir.fail_vector_reads.store(true, Ordering::SeqCst);
1740        let error = writer
1741            .build_vector_index()
1742            .await
1743            .expect_err("failed sample collection must fail the build")
1744            .to_string();
1745        assert!(
1746            error.contains("injected vector data read failure"),
1747            "{error}"
1748        );
1749
1750        assert!(
1751            !writer
1752                .segment_manager
1753                .read_metadata(|meta| meta.is_field_built(embedding.0))
1754                .await,
1755            "a failed build must not publish Built metadata"
1756        );
1757        assert!(
1758            writer.segment_manager.trained().is_none(),
1759            "a failed build must not publish trained artifacts"
1760        );
1761    }
1762
1763    #[tokio::test]
1764    async fn retrain_read_failure_keeps_the_complete_published_generation() {
1765        let mut sb = SchemaBuilder::default();
1766        let embedding = sb.add_dense_vector_field_with_config(
1767            "embedding",
1768            true,
1769            true,
1770            DenseVectorConfig::ivf_tq(READ_FAIL_DIM, Some(1), 1),
1771        );
1772        let schema = sb.build();
1773        let dir = VectorReadFailDirectory::default();
1774        let config = IndexConfig {
1775            merge_policy: Box::new(crate::merge::NoMergePolicy),
1776            num_indexing_threads: 1,
1777            ..Default::default()
1778        };
1779        let mut writer = IndexWriter::create(dir.clone(), schema, config)
1780            .await
1781            .unwrap();
1782        for i in 0..READ_FAIL_DOCS {
1783            let mut doc = Document::new();
1784            doc.add_dense_vector(embedding, vec![i as f32 + 1.0; READ_FAIL_DIM]);
1785            writer.add_document(doc).unwrap();
1786        }
1787        writer.commit().await.unwrap();
1788        writer.build_vector_index().await.unwrap();
1789
1790        let old_ids = writer.segment_manager.get_segment_ids().await;
1791        let old_meta = writer
1792            .segment_manager
1793            .read_metadata(|metadata| metadata.get_field_meta(embedding.0).cloned())
1794            .await
1795            .unwrap();
1796        let old_version = writer.segment_manager.trained().unwrap().centroids[&embedding.0].version;
1797
1798        dir.fail_all_vector_reads.store(true, Ordering::SeqCst);
1799        let error = writer
1800            .retrain_vector_index()
1801            .await
1802            .expect_err("failed sample collection must abort the retrain")
1803            .to_string();
1804        assert!(
1805            error.contains("injected vector data read failure"),
1806            "{error}"
1807        );
1808        assert_eq!(writer.segment_manager.get_segment_ids().await, old_ids);
1809        assert_eq!(
1810            writer
1811                .segment_manager
1812                .read_metadata(|metadata| metadata
1813                    .get_field_meta(embedding.0)
1814                    .map(|field| (field.centroids_file.clone(), field.codebook_file.clone())))
1815                .await,
1816            Some((old_meta.centroids_file, old_meta.codebook_file)),
1817        );
1818        assert_eq!(
1819            writer.segment_manager.trained().unwrap().centroids[&embedding.0].version,
1820            old_version,
1821        );
1822    }
1823}