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!(
597                "[vector_training] no dense vector fields configured for ANN indexing: index={}",
598                self.schema.index_label()
599            );
600            return Ok(());
601        }
602
603        let artifact_update = self.segment_manager.begin_vector_artifact_update().await?;
604        self.cleanup_unreferenced_vector_artifacts().await;
605
606        let fields_to_train = match mode {
607            VectorGenerationMode::BuildMissing => self.get_fields_to_build(&dense_fields).await,
608            VectorGenerationMode::RetrainAll => dense_fields.clone(),
609        };
610        for (_, config) in &fields_to_train {
611            validate_explicit_cluster_count(config.num_clusters())?;
612        }
613
614        let snapshot = self.segment_manager.acquire_snapshot().await;
615        if snapshot.is_empty() {
616            if mode == VectorGenerationMode::RetrainAll {
617                return Err(Error::Schema(
618                    "cannot retrain vector centroids without committed segments".into(),
619                ));
620            }
621            return Ok(());
622        }
623
624        let mut candidate_metadata = self.segment_manager.read_metadata(Clone::clone).await;
625        if !fields_to_train.is_empty() {
626            let total_vectors = self
627                .count_vectors_for_training(
628                    snapshot.segment_ids(),
629                    &fields_to_train,
630                    mode == VectorGenerationMode::BuildMissing,
631                )
632                .await?;
633            let artifact_generation = SegmentId::new().to_hex();
634            let updates = self
635                .train_fields(
636                    snapshot.segment_ids(),
637                    &fields_to_train,
638                    &total_vectors,
639                    &artifact_generation,
640                )
641                .await?;
642            for update in &updates {
643                candidate_metadata.init_field(update.field_id, update.index_type);
644                candidate_metadata.mark_field_built(
645                    update.field_id,
646                    update.vector_count,
647                    update.num_clusters,
648                    update.centroids_file.clone(),
649                    update.codebook_file.clone(),
650                );
651            }
652        }
653
654        let target_field_ids = dense_fields
655            .iter()
656            .filter_map(|(field, _)| {
657                candidate_metadata
658                    .is_field_built(field.0)
659                    .then_some(field.0)
660            })
661            .collect::<Vec<_>>();
662        if target_field_ids.is_empty() {
663            return Ok(());
664        }
665
666        let candidate_trained = super::IndexMetadata::try_load_trained_from_fields(
667            &candidate_metadata.vector_fields,
668            self.schema.as_ref(),
669            self.directory.as_ref(),
670        )
671        .await?
672        .map(Arc::new)
673        .ok_or_else(|| Error::Internal("candidate vector generation has no artifacts".into()))?;
674
675        let staged = self
676            .segment_manager
677            .stage_vector_generation(
678                &artifact_update,
679                snapshot.segment_ids(),
680                &target_field_ids,
681                Arc::clone(&candidate_trained),
682                mode == VectorGenerationMode::RetrainAll,
683            )
684            .await?;
685        self.segment_manager
686            .publish_vector_generation(
687                &artifact_update,
688                candidate_metadata.vector_fields,
689                candidate_trained,
690                staged,
691            )
692            .await?;
693
694        // Old readers retain the old snapshot and deserialized codebook. Once
695        // this local training snapshot drops, retired source files can be
696        // reclaimed. Reopening producers after the lease sees only the new set.
697        drop(snapshot);
698        drop(artifact_update);
699
700        // A producer that started while training was gated writes flat data.
701        // Catch already committed outputs; later commits carry their own
702        // targeted upgrade marker in PreparedSegment.
703        self.segment_manager
704            .rewrite_vector_segments(&target_field_ids)
705            .await?;
706        self.cleanup_unreferenced_vector_artifacts().await;
707        log::info!(
708            "[vector_training] ANN generation {:?} complete: index={} {} field(s)",
709            mode,
710            self.schema.index_label(),
711            target_field_ids.len(),
712        );
713        Ok(())
714    }
715
716    async fn train_fields(
717        &self,
718        segment_ids: &[String],
719        fields: &[(Field, IvfFieldConfig)],
720        total_vectors: &FxHashMap<u32, usize>,
721        artifact_generation: &str,
722    ) -> Result<Vec<TrainedFieldUpdate>> {
723        let training_pool = self.segment_manager.background_cpu_pool();
724        let index_label = self.schema.index_label();
725        let mut missing = Vec::new();
726        let mut updates = Vec::with_capacity(fields.len());
727        for (field, config) in fields {
728            // Sample collection and training are both field-serial. At most
729            // one bounded sample, one field's clustering scratch, and one
730            // generated artifact set can coexist.
731            let corpus_count = total_vectors.get(&field.0).copied().unwrap_or(0);
732            let Some(mut sample) = self
733                .collect_training_sample(segment_ids, *field, config, corpus_count)
734                .await?
735            else {
736                missing.push(field.0);
737                continue;
738            };
739            let model = crate::segment::block_in_place_if_multithread(|| {
740                training_pool.install(|| {
741                    Self::train_field_model(
742                        *field,
743                        config,
744                        &mut sample,
745                        corpus_count,
746                        artifact_generation,
747                        index_label,
748                    )
749                })
750            })?;
751            // Training artifacts own everything needed for persistence. Drop
752            // the potentially multi-gigabyte sample before async file I/O.
753            drop(sample);
754            updates.push(self.save_trained_field(model).await?);
755        }
756        if updates.is_empty() && !fields.is_empty() {
757            return Err(Error::Schema(format!(
758                "cannot train vector centroids: no committed vectors for field(s) {missing:?}"
759            )));
760        }
761        if !missing.is_empty() {
762            log::info!(
763                "[vector_training] skipping dense vector field(s) {missing:?}: index={index_label} has no vectors in the current corpus"
764            );
765        }
766        Ok(updates)
767    }
768
769    /// Remove abandoned generation-qualified artifacts from cancelled or
770    /// crash-interrupted attempts. The metadata references are the complete
771    /// live set, and the exclusive update lease prevents another trainer from
772    /// creating a candidate concurrently with this sweep.
773    async fn cleanup_unreferenced_vector_artifacts(&self) {
774        let referenced = self
775            .segment_manager
776            .read_metadata(|metadata| {
777                metadata
778                    .vector_fields
779                    .values()
780                    .flat_map(|field| {
781                        field
782                            .centroids_file
783                            .iter()
784                            .chain(field.codebook_file.iter())
785                    })
786                    .cloned()
787                    .collect::<std::collections::HashSet<_>>()
788            })
789            .await;
790        let files = match self.directory.list_files(std::path::Path::new("")).await {
791            Ok(files) => files,
792            Err(error) => {
793                log::warn!(
794                    "[trained] index={} failed listing abandoned dense vector artifacts: {error}",
795                    self.schema.index_label()
796                );
797                return;
798            }
799        };
800        for path in files {
801            let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
802                continue;
803            };
804            if !name.starts_with(VECTOR_ARTIFACT_PREFIX)
805                || referenced.contains(path.to_string_lossy().as_ref())
806            {
807                continue;
808            }
809            if let Err(error) = self.directory.delete(&path).await
810                && error.kind() != std::io::ErrorKind::NotFound
811            {
812                log::warn!(
813                    "[trained] index={} failed deleting abandoned artifact {path:?}: {error}",
814                    self.schema.index_label()
815                );
816            }
817        }
818    }
819
820    // ========================================================================
821    // Helper methods
822    // ========================================================================
823
824    fn reject_ann_fields(ann_fields: &[u32], id_str: &str, field_ids: &[u32]) -> Result<()> {
825        for &field_id in field_ids {
826            if ann_fields.binary_search(&field_id).is_ok() {
827                return Err(Error::Schema(format!(
828                    "metadata-flat field {field_id} already has ANN data in segment {id_str}; \
829                     recreate the index instead of mixing vector generations"
830                )));
831            }
832        }
833        Ok(())
834    }
835
836    /// Open only selected flat-vector fields plus the tiny segment metadata.
837    /// Training does not need term dictionaries, stores, sparse structures, or
838    /// corpus-sized ANN run columns, and must not pin those transient readers.
839    async fn load_training_vectors(
840        &self,
841        segment_id: SegmentId,
842        field_ids: &[u32],
843    ) -> Result<crate::segment::reader::loader::VectorsFileData> {
844        let files = SegmentFiles::new(segment_id.0);
845        let meta_bytes = self
846            .directory
847            .open_read(&files.meta)
848            .await?
849            .read_bytes()
850            .await?;
851        let meta = SegmentMeta::deserialize(meta_bytes.as_slice())?;
852        if meta.id != segment_id.0 {
853            return Err(Error::Corruption(format!(
854                "segment metadata ID {:032x} does not match file ID {}",
855                meta.id,
856                segment_id.to_hex(),
857            )));
858        }
859        crate::segment::reader::loader::load_flat_vectors_file(
860            self.directory.as_ref(),
861            &files,
862            self.schema.as_ref(),
863            meta.num_docs,
864            field_ids,
865        )
866        .await
867    }
868
869    /// Get all dense vector fields that need ANN indexes
870    fn get_ivf_vector_fields(&self) -> Vec<(Field, IvfFieldConfig)> {
871        self.schema
872            .fields()
873            .filter_map(|(field, entry)| {
874                if entry.field_type == FieldType::DenseVector && entry.indexed {
875                    entry
876                        .dense_vector_config
877                        .as_ref()
878                        // Flat is a pre-build storage state; the production ANN
879                        // path is trained once and shared by every segment.
880                        .filter(|c| c.uses_ivf())
881                        .map(|c| (field, IvfFieldConfig::Float(c.clone())))
882                } else if entry.field_type == FieldType::BinaryDenseVector && entry.indexed {
883                    entry
884                        .binary_dense_vector_config
885                        .as_ref()
886                        .filter(|config| config.index_type == BinaryIndexType::Ivf)
887                        .map(|config| (field, IvfFieldConfig::Binary(config.clone())))
888                } else {
889                    None
890                }
891            })
892            .collect()
893    }
894
895    /// Get fields that need building (not already built)
896    async fn get_fields_to_build(
897        &self,
898        dense_fields: &[(Field, IvfFieldConfig)],
899    ) -> Vec<(Field, IvfFieldConfig)> {
900        let field_ids: Vec<u32> = dense_fields.iter().map(|(f, _)| f.0).collect();
901        let built: Vec<u32> = self
902            .segment_manager
903            .read_metadata(|meta| {
904                field_ids
905                    .iter()
906                    .filter(|fid| meta.is_field_built(**fid))
907                    .copied()
908                    .collect()
909            })
910            .await;
911        dense_fields
912            .iter()
913            .filter(|(field, _)| !built.contains(&field.0))
914            .cloned()
915            .collect()
916    }
917
918    /// Count every configured field without reading any vector payload bytes.
919    async fn count_vectors_for_training(
920        &self,
921        segment_ids: &[String],
922        fields_to_build: &[(Field, IvfFieldConfig)],
923        require_flat_generation: bool,
924    ) -> Result<FxHashMap<u32, usize>> {
925        let mut total_vectors: FxHashMap<u32, usize> = FxHashMap::default();
926        let field_ids: Vec<u32> = fields_to_build.iter().map(|(field, _)| field.0).collect();
927
928        // Initial construction rejects
929        // ANN payloads for metadata-flat fields; an explicit retrain reads the
930        // exact flat vectors retained beside the current ANN generation.
931        for id_str in segment_ids {
932            let segment_id = SegmentId::from_hex(id_str)
933                .ok_or_else(|| Error::Corruption(format!("Invalid segment ID: {}", id_str)))?;
934            let vectors = self.load_training_vectors(segment_id, &field_ids).await?;
935
936            if require_flat_generation {
937                Self::reject_ann_fields(&vectors.ann_fields, id_str, &field_ids)?;
938            }
939
940            for (field, _) in fields_to_build {
941                if let Some(flat) = vectors.flat_vectors.get(&field.0) {
942                    let total = total_vectors.entry(field.0).or_default();
943                    *total = total.checked_add(flat.num_vectors).ok_or_else(|| {
944                        Error::Corruption(format!(
945                            "vector count overflows usize for field {}",
946                            field.0,
947                        ))
948                    })?;
949                }
950            }
951        }
952        Ok(total_vectors)
953    }
954
955    /// Fetch one deterministic, uniform field sample from the pinned segment
956    /// snapshot. Only selected ranges are read; all other corpus vectors stay
957    /// on disk. The caller trains and drops this sample before moving to the
958    /// next field.
959    async fn collect_training_sample(
960        &self,
961        segment_ids: &[String],
962        field: Field,
963        config: &IvfFieldConfig,
964        total: usize,
965    ) -> Result<Option<TrainingSample>> {
966        if total == 0 {
967            return Ok(None);
968        }
969        let bytes_per_sample = match config {
970            IvfFieldConfig::Float(config) => config
971                .dim
972                .checked_mul(size_of::<f32>())
973                .ok_or_else(|| Error::Schema("float training vector size overflows".into()))?,
974            IvfFieldConfig::Binary(config) => config.dim.div_ceil(8),
975        };
976        let limit = training_sample_limit(
977            self.config.vector_training_max_samples,
978            self.config.vector_training_memory_bytes,
979            bytes_per_sample,
980        )?;
981        let take = final_training_sample_count(config, total, limit)?;
982        let sample_seed = 0x4845_524d_4553_4956 ^ field.0 as u64 ^ total as u64;
983        let ordinals = deterministic_sample_ordinals(total, take, sample_seed);
984
985        let mut sample = match config {
986            IvfFieldConfig::Float(config) => TrainingSample::Float(Vec::with_capacity(
987                take.checked_mul(config.dim)
988                    .ok_or_else(|| Error::Schema("float training sample size overflows".into()))?,
989            )),
990            IvfFieldConfig::Binary(_) => TrainingSample::Binary(Vec::with_capacity(
991                take.checked_mul(bytes_per_sample)
992                    .ok_or_else(|| Error::Schema("binary training sample size overflows".into()))?,
993            )),
994        };
995        let max_read_vectors = (MAX_SAMPLE_READ_BYTES / bytes_per_sample.max(1)).max(1);
996        let mut zero_codes = 0usize;
997        let mut global_offset = 0usize;
998        let mut cursor = 0usize;
999        let field_ids = [field.0];
1000
1001        for id_str in segment_ids {
1002            let segment_id = SegmentId::from_hex(id_str)
1003                .ok_or_else(|| Error::Corruption(format!("Invalid segment ID: {id_str}")))?;
1004            let vectors = self.load_training_vectors(segment_id, &field_ids).await?;
1005
1006            let Some(lazy_flat) = vectors.flat_vectors.get(&field.0) else {
1007                continue;
1008            };
1009            let base = global_offset;
1010            let end = base.checked_add(lazy_flat.num_vectors).ok_or_else(|| {
1011                Error::Corruption(format!("vector offset overflows for field {}", field.0))
1012            })?;
1013            global_offset = end;
1014            let first = cursor;
1015            while cursor < ordinals.len() && ordinals[cursor] < end {
1016                cursor += 1;
1017            }
1018            let selected = &ordinals[first..cursor];
1019            let mut run_start = 0;
1020            while run_start < selected.len() {
1021                let mut run_end = run_start + 1;
1022                while run_end < selected.len() {
1023                    let selected_count = run_end - run_start + 1;
1024                    let span = selected[run_end] - selected[run_start] + 1;
1025                    if span > max_read_vectors
1026                        || span > selected_count.saturating_mul(MAX_SAMPLE_READ_AMPLIFICATION)
1027                    {
1028                        break;
1029                    }
1030                    run_end += 1;
1031                }
1032                let local_start = selected[run_start] - base;
1033                let read_len = selected[run_end - 1] - selected[run_start] + 1;
1034                let bytes = lazy_flat
1035                    .read_vectors_batch(local_start, read_len)
1036                    .await
1037                    .map_err(crate::Error::Io)?;
1038                match &mut sample {
1039                    TrainingSample::Binary(codes) => {
1040                        let expected = read_len.checked_mul(bytes_per_sample).ok_or_else(|| {
1041                            Error::Corruption("binary sample read size overflows".into())
1042                        })?;
1043                        if bytes.len() != expected {
1044                            return Err(Error::Corruption(format!(
1045                                "binary sample read returned {} bytes, expected {expected}",
1046                                bytes.len(),
1047                            )));
1048                        }
1049                        for &ordinal in &selected[run_start..run_end] {
1050                            let relative = ordinal - selected[run_start];
1051                            let offset = relative * bytes_per_sample;
1052                            let code = &bytes.as_slice()[offset..offset + bytes_per_sample];
1053                            // All-zero codes are never indexed, so training on
1054                            // them only spends centroids that can never be
1055                            // assigned: a production field turned ~30% of a
1056                            // 163k codebook into duplicate zero centroids.
1057                            if code.iter().all(|&byte| byte == 0) {
1058                                zero_codes += 1;
1059                                continue;
1060                            }
1061                            codes.extend_from_slice(code);
1062                        }
1063                    }
1064                    TrainingSample::Float(values) => {
1065                        let dim = lazy_flat.dim;
1066                        let float_count = read_len.checked_mul(dim).ok_or_else(|| {
1067                            Error::Corruption("float sample read size overflows".into())
1068                        })?;
1069                        let mut decoded = vec![0.0; float_count];
1070                        crate::segment::dequantize_raw(
1071                            bytes.as_slice(),
1072                            lazy_flat.quantization,
1073                            decoded.len(),
1074                            &mut decoded,
1075                        )
1076                        .map_err(crate::Error::Io)?;
1077                        for &ordinal in &selected[run_start..run_end] {
1078                            let relative = ordinal - selected[run_start];
1079                            let offset = relative * dim;
1080                            values.extend_from_slice(&decoded[offset..offset + dim]);
1081                        }
1082                    }
1083                }
1084                run_start = run_end;
1085            }
1086        }
1087
1088        let collected = sample.len(config.dim());
1089        // Coverage is checked against what was *selected*; withheld all-zero
1090        // codes are subtracted explicitly so a real traversal bug still trips.
1091        if global_offset != total || cursor != take || collected + zero_codes != take {
1092            return Err(Error::Corruption(format!(
1093                "training sample coverage mismatch for field {}: counted={total}, traversed={global_offset}, selected={cursor}, collected={collected}, zero={zero_codes}",
1094                field.0,
1095            )));
1096        }
1097        if zero_codes > 0 {
1098            log::warn!(
1099                "[vector_training] index={} field={}: {zero_codes} of {take} sampled vectors \
1100                 ({:.1}%) are all-zero and were excluded from training — they cannot be assigned \
1101                 to any leaf, so training on them only wastes centroids",
1102                self.schema.index_label(),
1103                field.0,
1104                100.0 * zero_codes as f64 / take.max(1) as f64,
1105            );
1106            crate::observe::binary_zero_vectors(
1107                self.schema.index_label(),
1108                field.0,
1109                zero_codes,
1110                take,
1111            );
1112        }
1113        if collected == 0 {
1114            log::warn!(
1115                "[vector_training] index={} field={}: every sampled vector is all-zero; \
1116                 skipping ANN training for this field",
1117                self.schema.index_label(),
1118                field.0,
1119            );
1120            return Ok(None);
1121        }
1122        if collected < total {
1123            log::info!(
1124                "[vector_training] sampled {} / {} dense vectors: index={} field={} (max {} vectors / {} resident)",
1125                collected,
1126                total,
1127                self.schema.index_label(),
1128                field.0,
1129                self.config.vector_training_max_samples,
1130                crate::format_bytes(self.config.vector_training_memory_bytes as u64),
1131            );
1132        }
1133        Ok(Some(sample))
1134    }
1135
1136    /// Train one field. Called from the shared bounded Rayon pool, so fields
1137    /// and each field's internal clustering work compose without extra pools.
1138    fn train_field_model(
1139        field: Field,
1140        config: &IvfFieldConfig,
1141        sample: &mut TrainingSample,
1142        corpus_count: usize,
1143        artifact_generation: &str,
1144        index_label: &str,
1145    ) -> Result<TrainedFieldModel> {
1146        let field_id = field.0;
1147        let dim = config.dim();
1148        let sample_count = sample.len(dim);
1149        if sample_count == 0 || corpus_count == 0 {
1150            return Err(Error::Internal(format!(
1151                "empty training sample for non-empty field {field_id}"
1152            )));
1153        }
1154        let num_clusters = effective_field_num_clusters(config, corpus_count, sample_count)?;
1155
1156        log::info!(
1157            "[vector_training] training model: index={} field={} with {} sampled / {} total vectors, {} clusters (dim={})",
1158            index_label,
1159            field_id,
1160            sample_count,
1161            corpus_count,
1162            num_clusters,
1163            dim,
1164        );
1165
1166        let centroids_filename =
1167            format!("{VECTOR_ARTIFACT_PREFIX}{artifact_generation}_field_{field_id}_centroids.bin");
1168
1169        let artifacts = match (config, sample) {
1170            (IvfFieldConfig::Float(config), TrainingSample::Float(values))
1171                if config.index_type == VectorIndexType::IvfTq =>
1172            {
1173                values
1174                    .chunks_exact_mut(dim)
1175                    .for_each(crate::structures::vector::ivf::routing::normalize_cosine_in_place);
1176                let candidate_validation_count =
1177                    validation_sample_count(sample_count, num_clusters, dim);
1178                let candidate_training_count = sample_count - candidate_validation_count;
1179                let seeds = model_selection_seeds(
1180                    candidate_training_count,
1181                    num_clusters,
1182                    dim,
1183                    candidate_validation_count > 0,
1184                );
1185                let split_seed =
1186                    MODEL_SELECTION_SEEDS[0] ^ u64::from(field_id) ^ corpus_count as u64;
1187                let split = if seeds.len() > 1 {
1188                    partition_contiguous_holdout_suffix(
1189                        values.as_mut_slice(),
1190                        dim,
1191                        candidate_validation_count,
1192                        split_seed,
1193                    )
1194                } else {
1195                    values.len()
1196                };
1197                let (training_values, validation) = values.as_slice().split_at(split);
1198                let training_count = training_values.len() / dim;
1199                let validation_count = validation.len() / dim;
1200                if seeds.len() > 1 {
1201                    log::info!(
1202                        "[vector_training] model selection: index={index_label} field={field_id}, {} training + {} held-out vectors, {} deterministic centroid seed(s)",
1203                        training_count,
1204                        validation_count,
1205                        seeds.len(),
1206                    );
1207                } else {
1208                    log::info!(
1209                        "[vector_training] model selection: index={index_label} field={field_id}, all {} sampled vectors with one deterministic \
1210                         centroid seed; model-selection holdout disabled",
1211                        training_count,
1212                    );
1213                }
1214
1215                let mut base_config = crate::structures::CoarseConfig::new(dim, num_clusters)
1216                    .with_routing(config.ivf_routing);
1217                if let Some(soar) = config.soar.clone() {
1218                    base_config = base_config.with_soar(soar);
1219                }
1220
1221                let mut selected: Option<(
1222                    crate::structures::CoarseCentroids,
1223                    Option<FloatBuildQuality>,
1224                    u64,
1225                )> = None;
1226                for &seed in seeds {
1227                    let candidate = crate::structures::CoarseCentroids::train_contiguous(
1228                        &base_config.clone().with_seed(seed),
1229                        training_values,
1230                        training_count,
1231                        index_label,
1232                    );
1233                    let quality =
1234                        evaluate_float_build_quality(&candidate, validation, config.ivf_routing);
1235                    if let Some(quality) = quality {
1236                        log::info!(
1237                            "[vector_training] IVF candidate: index={index_label} field={field_id} seed={seed}, objective={:.6}, \
1238                             exact/routed_mean_distortion={:.6}/{:.6}, router_recall@1={:.4}, \
1239                             construction_postings/vector={:.3}, \
1240                             residual_scale[p50/p95/p99]={:.4}/{:.4}/{:.4}, \
1241                             construction_occupancy[p95/p99/max/empty]={}/{}/{}/{}",
1242                            quality.objective,
1243                            quality.mean_exact_distortion,
1244                            quality.mean_routed_distortion,
1245                            quality.router_recall_at_1,
1246                            quality.mean_construction_assignments,
1247                            quality.residual_p50,
1248                            quality.residual_p95,
1249                            quality.residual_p99,
1250                            quality.occupancy.p95,
1251                            quality.occupancy.p99,
1252                            quality.occupancy.max,
1253                            quality.occupancy.empty,
1254                        );
1255                    }
1256                    let replace = selected.as_ref().is_none_or(|(_, best, _)| {
1257                        quality
1258                            .map(|quality| quality.objective)
1259                            .unwrap_or(f64::INFINITY)
1260                            .total_cmp(
1261                                &best
1262                                    .map(|quality| quality.objective)
1263                                    .unwrap_or(f64::INFINITY),
1264                            )
1265                            .is_lt()
1266                    });
1267                    if replace {
1268                        selected = Some((candidate, quality, seed));
1269                    }
1270                }
1271                let (mut centroids, quality, seed) =
1272                    selected.expect("the fixed centroid seed bank is non-empty");
1273                if let Some(quality) = quality {
1274                    log::info!(
1275                        "[vector_training] selected IVF seed: index={index_label} field={field_id} seed={seed}, held-out objective {:.6} \
1276                         (occupancy penalty {:.4})",
1277                        quality.objective,
1278                        quality.occupancy.penalty,
1279                    );
1280                } else {
1281                    log::info!(
1282                        "[vector_training] selected IVF seed: index={index_label} field={field_id} seed={seed}, without a model-selection \
1283                         holdout",
1284                    );
1285                }
1286                centroids.version =
1287                    crate::structures::mark_ivf_tq_cosine_generation(centroids.version);
1288                TrainedFieldArtifacts::FloatCentroids(centroids)
1289            }
1290            (IvfFieldConfig::Binary(config), TrainingSample::Binary(codes)) => {
1291                let byte_len = dim.div_ceil(8);
1292                let training_count = codes.len() / byte_len;
1293                let mut binary_config = crate::structures::BinaryIvfConfig::new(dim, num_clusters);
1294                binary_config.max_train_samples = training_count;
1295                binary_config.routing = config.ivf_routing;
1296                let quantizer = crate::structures::BinaryCoarseQuantizer::train(
1297                    binary_config,
1298                    codes,
1299                    training_count,
1300                    index_label,
1301                )
1302                .map_err(Error::Io)?;
1303                TrainedFieldArtifacts::Binary(quantizer)
1304            }
1305            _ => {
1306                return Err(Error::Internal(format!(
1307                    "training sample kind does not match field {field_id}"
1308                )));
1309            }
1310        };
1311
1312        let actual_num_clusters = match &artifacts {
1313            TrainedFieldArtifacts::FloatCentroids(centroids) => centroids.num_clusters as usize,
1314            TrainedFieldArtifacts::Binary(quantizer) => quantizer.num_clusters as usize,
1315        };
1316        Ok(TrainedFieldModel {
1317            update: TrainedFieldUpdate {
1318                field_id,
1319                index_type: config.index_type(),
1320                vector_count: corpus_count,
1321                num_clusters: actual_num_clusters,
1322                centroids_file: centroids_filename,
1323                codebook_file: None,
1324            },
1325            artifacts,
1326        })
1327    }
1328
1329    async fn save_trained_field(&self, model: TrainedFieldModel) -> Result<TrainedFieldUpdate> {
1330        let TrainedFieldModel { update, artifacts } = model;
1331        match artifacts {
1332            TrainedFieldArtifacts::FloatCentroids(centroids) => {
1333                self.save_trained_artifact(&centroids, &update.centroids_file)
1334                    .await?;
1335                log::info!(
1336                    "[vector_training] saved IVF-TQ coarse artifact: index={} field={} ({} clusters; leaf codec is derived)",
1337                    self.schema.index_label(),
1338                    update.field_id,
1339                    centroids.num_clusters,
1340                );
1341            }
1342            TrainedFieldArtifacts::Binary(quantizer) => {
1343                self.save_trained_artifact(&quantizer, &update.centroids_file)
1344                    .await?;
1345                log::info!(
1346                    "[vector_training] saved binary IVF artifact: index={} field={} ({} clusters)",
1347                    self.schema.index_label(),
1348                    update.field_id,
1349                    quantizer.num_clusters,
1350                );
1351            }
1352        }
1353        Ok(update)
1354    }
1355
1356    /// Serialize a trained structure to bincode and save to an index-level file.
1357    async fn save_trained_artifact(
1358        &self,
1359        artifact: &impl serde::Serialize,
1360        filename: &str,
1361    ) -> Result<()> {
1362        let temp_filename = format!("{filename}.tmp");
1363        let temp_path = std::path::Path::new(&temp_filename);
1364        let final_path = std::path::Path::new(filename);
1365        let mut writer = self.directory.streaming_writer(temp_path).await?;
1366        let encode_result = {
1367            let mut limited = SizeLimitedWriter::new(
1368                writer.as_mut(),
1369                super::metadata::MAX_TRAINED_ARTIFACT_BYTES,
1370            );
1371            bincode::serde::encode_into_std_write(
1372                artifact,
1373                &mut limited,
1374                bincode::config::standard(),
1375            )
1376        };
1377        if let Err(error) = encode_result {
1378            drop(writer);
1379            let _ = self.directory.delete(temp_path).await;
1380            return Err(Error::Serialization(format!(
1381                "failed to serialize trained artifact '{filename}': {error}"
1382            )));
1383        }
1384        if let Err(error) = writer.finish() {
1385            let _ = self.directory.delete(temp_path).await;
1386            return Err(Error::Io(error));
1387        }
1388        if let Err(error) = self.directory.rename(temp_path, final_path).await {
1389            let _ = self.directory.delete(temp_path).await;
1390            return Err(Error::Io(error));
1391        }
1392        self.directory.sync().await?;
1393        Ok(())
1394    }
1395}
1396
1397#[cfg(test)]
1398mod tests {
1399    use super::*;
1400
1401    fn ivf_config(num_clusters: Option<usize>) -> DenseVectorConfig {
1402        DenseVectorConfig::ivf_tq(8, num_clusters, 4)
1403    }
1404
1405    #[test]
1406    fn effective_clusters_follow_corpus_heuristic_but_fit_sample() {
1407        let config = ivf_config(None);
1408
1409        assert_eq!(
1410            effective_ivf_num_clusters(&config, 1_000_000, 73).unwrap(),
1411            16
1412        );
1413        assert_eq!(
1414            effective_ivf_num_clusters(&config, 10_000, 1_000).unwrap(),
1415            25
1416        );
1417    }
1418
1419    #[test]
1420    fn effective_clusters_clamp_explicit_value_to_sample() {
1421        let config = ivf_config(Some(256));
1422        assert_eq!(
1423            effective_ivf_num_clusters(&config, 1_000_000, 17).unwrap(),
1424            17
1425        );
1426    }
1427
1428    #[test]
1429    fn effective_clusters_reject_invalid_explicit_bounds() {
1430        let zero = effective_ivf_num_clusters(&ivf_config(Some(0)), 10_000, 100)
1431            .unwrap_err()
1432            .to_string();
1433        assert!(zero.contains("at least 1"));
1434
1435        let too_many =
1436            effective_ivf_num_clusters(&ivf_config(Some(MAX_IVF_CLUSTERS + 1)), 10_000, 100)
1437                .unwrap_err()
1438                .to_string();
1439        assert!(too_many.contains("must not exceed 1048576"));
1440    }
1441
1442    #[test]
1443    fn effective_clusters_reject_empty_training_sample() {
1444        let error = effective_ivf_num_clusters(&ivf_config(None), 10_000, 0)
1445            .unwrap_err()
1446            .to_string();
1447        assert!(error.contains("without sample vectors"));
1448    }
1449
1450    #[test]
1451    fn training_sample_limit_honors_both_cli_bounds() {
1452        assert_eq!(training_sample_limit(10_000_000, 4_096, 4).unwrap(), 1_024);
1453        assert_eq!(training_sample_limit(100, 4_096, 4).unwrap(), 100);
1454        let error = training_sample_limit(100, 3, 4).unwrap_err().to_string();
1455        assert!(error.contains("cannot hold one"), "{error}");
1456    }
1457
1458    #[test]
1459    fn final_sample_is_selected_at_the_points_per_centroid_ceiling() {
1460        let config = IvfFieldConfig::Float(DenseVectorConfig::ivf_tq(8, Some(4), 1));
1461        assert_eq!(
1462            final_training_sample_count(&config, 10_000, 10_000).unwrap(),
1463            4 * COARSE_TRAINING_POINTS_PER_CENTROID,
1464        );
1465        assert_eq!(
1466            final_training_sample_count(&config, 10_000, 512).unwrap(),
1467            512,
1468        );
1469    }
1470
1471    #[test]
1472    fn holdout_preserves_the_training_points_per_centroid_floor() {
1473        assert_eq!(validation_sample_count(390, 10, 8), 0);
1474        assert_eq!(validation_sample_count(391, 10, 8), 1);
1475
1476        let config = IvfFieldConfig::Float(ivf_config(None));
1477        let sample_count = 1_000;
1478        let clusters = effective_field_num_clusters(&config, 10_000, sample_count).unwrap();
1479        let held_out = validation_sample_count(sample_count, clusters, config.dim());
1480        assert!(
1481            sample_count - held_out >= clusters.saturating_mul(MIN_TRAINING_POINTS_PER_CENTROID)
1482        );
1483    }
1484
1485    #[test]
1486    fn deterministic_point_sample_is_sorted_unique_and_repeatable() {
1487        let first = deterministic_sample_ordinals(10_000, 1_000, 7);
1488        let repeated = deterministic_sample_ordinals(10_000, 1_000, 7);
1489        let other_seed = deterministic_sample_ordinals(10_000, 1_000, 8);
1490        assert_eq!(first, repeated);
1491        assert_ne!(first, other_seed);
1492        assert_eq!(first.len(), 1_000);
1493        assert!(first.windows(2).all(|pair| pair[0] < pair[1]));
1494        assert!(first.iter().all(|&ordinal| ordinal < 10_000));
1495    }
1496
1497    #[test]
1498    fn model_selection_accounts_for_initialization_and_all_lloyd_passes() {
1499        assert_eq!(model_selection_seeds(1_000, 16, 8, true).len(), 2);
1500        assert_eq!(model_selection_seeds(1_000, 16, 8, false).len(), 1);
1501        // A single assignment pass is only 180M coordinate comparisons, but
1502        // two complete initialization/refinement candidates exceed the budget.
1503        assert_eq!(model_selection_seeds(1_800, 1_000, 100, true).len(), 1);
1504        assert_eq!(
1505            model_selection_seeds(MAX_MULTI_SEED_COORDINATE_WORK, 2, 1, true).len(),
1506            1,
1507        );
1508    }
1509
1510    #[test]
1511    fn flat_float_sample_partitions_a_deterministic_holdout_suffix() {
1512        let original: Vec<f32> = (0..20).map(|value| value as f32).collect();
1513        let mut first = original.clone();
1514        let mut repeated = original.clone();
1515        let validation_count = validation_sample_count(10, 2, 2);
1516        let first_split = partition_contiguous_holdout_suffix(&mut first, 2, validation_count, 11);
1517        let repeated_split =
1518            partition_contiguous_holdout_suffix(&mut repeated, 2, validation_count, 11);
1519
1520        assert_eq!(first, repeated);
1521        assert_eq!(first_split, repeated_split);
1522        assert_eq!(first_split, 18);
1523        assert_eq!(first.len(), original.len());
1524        assert_eq!(first[first_split..].len(), 2);
1525
1526        let mut first_components: Vec<u32> =
1527            first.chunks_exact(2).map(|row| row[0] as u32).collect();
1528        first_components.sort_unstable();
1529        assert_eq!(first_components, vec![0, 2, 4, 6, 8, 10, 12, 14, 16, 18]);
1530        assert!(first.chunks_exact(2).all(|row| row[1] == row[0] + 1.0));
1531    }
1532
1533    #[test]
1534    fn occupancy_report_exposes_tail_and_empty_cells() {
1535        let report = occupancy_quality(vec![0, 1, 2, 7], 10);
1536        assert_eq!(report.p95, 7);
1537        assert_eq!(report.p99, 7);
1538        assert_eq!(report.max, 7);
1539        assert_eq!(report.empty, 1);
1540        assert!(report.penalty > 0.0);
1541    }
1542
1543    #[test]
1544    fn model_selection_objective_prices_routed_distortion_excess() {
1545        let objective = float_model_selection_objective(2.0, 3.0, 0.1);
1546        assert!((objective - 3.2).abs() < f64::EPSILON);
1547        assert_eq!(float_model_selection_objective(2.0, 1.5, 0.0), 2.0);
1548    }
1549
1550    #[test]
1551    fn float_quality_reports_exact_query_router_recall() {
1552        let training = [0.0, 0.0, 0.1, 0.0, 10.0, 10.0, 10.1, 10.0];
1553        let config = crate::structures::CoarseConfig::new(2, 2);
1554        let centroids = crate::structures::CoarseCentroids::train_contiguous(
1555            &config,
1556            &training,
1557            4,
1558            "test-index",
1559        );
1560        for routing in [
1561            crate::dsl::IvfRoutingMode::Flat,
1562            crate::dsl::IvfRoutingMode::Auto,
1563        ] {
1564            let quality =
1565                evaluate_float_build_quality(&centroids, &[0.05, 0.0, 10.05, 10.0], routing)
1566                    .unwrap();
1567
1568            assert_eq!(quality.router_recall_at_1, 1.0);
1569            assert!(
1570                (quality.mean_exact_distortion - quality.mean_routed_distortion).abs()
1571                    < f64::EPSILON
1572            );
1573            assert_eq!(quality.mean_construction_assignments, 1.0);
1574            assert_eq!(quality.occupancy.empty, 0);
1575        }
1576    }
1577
1578    #[test]
1579    fn float_quality_counts_soar_secondary_postings_in_occupancy() {
1580        let training = [0.0, 0.0, 0.1, 0.0, 10.0, 10.0, 10.1, 10.0];
1581        let config = crate::structures::CoarseConfig::new(2, 2)
1582            .with_routing(crate::dsl::IvfRoutingMode::Flat)
1583            .with_soar(crate::structures::SoarConfig::full());
1584        let centroids = crate::structures::CoarseCentroids::train_contiguous(
1585            &config,
1586            &training,
1587            4,
1588            "test-index",
1589        );
1590        let quality = evaluate_float_build_quality(
1591            &centroids,
1592            &[0.05, 0.0, 10.05, 10.0],
1593            crate::dsl::IvfRoutingMode::Flat,
1594        )
1595        .unwrap();
1596
1597        assert_eq!(quality.mean_construction_assignments, 2.0);
1598        assert_eq!(quality.occupancy.empty, 0);
1599    }
1600
1601    #[test]
1602    fn artifact_writer_enforces_limit_without_writing_past_it() {
1603        let mut output = Vec::new();
1604        let mut writer = SizeLimitedWriter::new(&mut output, 3);
1605        writer.write_all(&[1, 2]).unwrap();
1606        let error = writer.write_all(&[3, 4]).unwrap_err().to_string();
1607        assert!(error.contains("3-byte safety limit"), "{error}");
1608        assert_eq!(output, vec![1, 2]);
1609    }
1610
1611    #[test]
1612    fn ivf_tq_training_marks_generation_normalizes_and_calibrates_default_soar() {
1613        let config = IvfFieldConfig::Float(DenseVectorConfig::ivf_tq(2, Some(1), 1));
1614        let mut sample = TrainingSample::Float(vec![3.0, 4.0, 30.0, 40.0, 300.0, 400.0]);
1615        let model = IndexWriter::<crate::directories::RamDirectory>::train_field_model(
1616            Field(3),
1617            &config,
1618            &mut sample,
1619            3,
1620            "test",
1621            "test-index",
1622        )
1623        .unwrap();
1624        let TrainedFieldArtifacts::FloatCentroids(centroids) = model.artifacts else {
1625            panic!("expected float IVF-TQ centroids");
1626        };
1627
1628        assert!(crate::structures::is_ivf_tq_cosine_generation(
1629            centroids.version
1630        ));
1631        assert!((centroids.centroids[0] - 0.6).abs() < 1e-6);
1632        assert!((centroids.centroids[1] - 0.8).abs() < 1e-6);
1633        let soar = centroids
1634            .soar_config
1635            .as_ref()
1636            .expect("default SOAR should propagate into the trained router");
1637        assert_eq!(soar.num_secondary, 1);
1638        assert!(soar.selective);
1639        assert!(
1640            soar.spill_threshold > 0.0,
1641            "the negative 30% target tag should be replaced by a calibrated threshold"
1642        );
1643        assert_eq!(soar.calibration_target(), None);
1644    }
1645
1646    #[test]
1647    fn explicitly_disabled_soar_stays_off_during_ivf_tq_training() {
1648        let config = IvfFieldConfig::Float(DenseVectorConfig::ivf_tq(2, Some(1), 1).without_soar());
1649        let mut sample = TrainingSample::Float(vec![3.0, 4.0, 30.0, 40.0, 300.0, 400.0]);
1650        let model = IndexWriter::<crate::directories::RamDirectory>::train_field_model(
1651            Field(3),
1652            &config,
1653            &mut sample,
1654            3,
1655            "test-no-soar",
1656            "test-index",
1657        )
1658        .unwrap();
1659        let TrainedFieldArtifacts::FloatCentroids(centroids) = model.artifacts else {
1660            panic!("expected float IVF-TQ centroids");
1661        };
1662
1663        assert!(centroids.soar_config.is_none());
1664    }
1665
1666    // ===== rebuild destructive-downgrade regression tests =====
1667
1668    use std::path::Path;
1669    use std::sync::atomic::{AtomicBool, Ordering};
1670
1671    use crate::directories::{
1672        Directory, DirectoryWriter as DirectoryWriterTrait, FileHandle, RamDirectory, RangeReadFn,
1673    };
1674    use crate::dsl::{Document, SchemaBuilder};
1675    use crate::index::{IndexConfig, IndexWriter};
1676
1677    const READ_FAIL_DOCS: usize = 5;
1678    const READ_FAIL_DIM: usize = 4;
1679    /// Flat entry layout of a single-field, flat-only `.vectors` file written
1680    /// by the segment builder (data-first format): header (16 bytes) + raw f32
1681    /// vectors + doc-id map + TOC + footer. Only the raw vector region is read
1682    /// by training collection; segment open touches the header, doc-id map,
1683    /// TOC, and footer, which all live outside this byte range.
1684    const VEC_REGION_START: u64 = 16;
1685    const VEC_REGION_END: u64 = VEC_REGION_START + (READ_FAIL_DOCS * READ_FAIL_DIM * 4) as u64;
1686
1687    /// RamDirectory wrapper whose `.vectors` handles fail range reads of the
1688    /// raw vector region while `fail_vector_reads` is armed. Segment open
1689    /// keeps succeeding, so exactly the training-collection batch reads fail —
1690    /// the I/O the rebuild path used to swallow with `if let Ok`.
1691    #[derive(Clone, Default)]
1692    struct VectorReadFailDirectory {
1693        inner: RamDirectory,
1694        fail_vector_reads: Arc<AtomicBool>,
1695        fail_all_vector_reads: Arc<AtomicBool>,
1696    }
1697
1698    #[async_trait::async_trait]
1699    impl Directory for VectorReadFailDirectory {
1700        async fn exists(&self, path: &Path) -> std::io::Result<bool> {
1701            self.inner.exists(path).await
1702        }
1703
1704        async fn file_size(&self, path: &Path) -> std::io::Result<u64> {
1705            self.inner.file_size(path).await
1706        }
1707
1708        async fn open_read(&self, path: &Path) -> std::io::Result<FileHandle> {
1709            self.inner.open_read(path).await
1710        }
1711
1712        async fn read_range(
1713            &self,
1714            path: &Path,
1715            range: std::ops::Range<u64>,
1716        ) -> std::io::Result<crate::directories::OwnedBytes> {
1717            self.inner.read_range(path, range).await
1718        }
1719
1720        async fn list_files(&self, prefix: &Path) -> std::io::Result<Vec<std::path::PathBuf>> {
1721            self.inner.list_files(prefix).await
1722        }
1723
1724        async fn open_lazy(&self, path: &Path) -> std::io::Result<FileHandle> {
1725            let handle = self.inner.open_lazy(path).await?;
1726            if path.extension().is_some_and(|ext| ext == "vectors") {
1727                let armed = Arc::clone(&self.fail_vector_reads);
1728                let fail_all = Arc::clone(&self.fail_all_vector_reads);
1729                let len = handle.len();
1730                let read_fn: RangeReadFn = Arc::new(move |range: std::ops::Range<u64>| {
1731                    let handle = handle.clone();
1732                    let armed = Arc::clone(&armed);
1733                    let fail_all = Arc::clone(&fail_all);
1734                    Box::pin(async move {
1735                        if fail_all.load(Ordering::SeqCst)
1736                            || (armed.load(Ordering::SeqCst)
1737                                && range.start >= VEC_REGION_START
1738                                && range.end <= VEC_REGION_END)
1739                        {
1740                            return Err(std::io::Error::other("injected vector data read failure"));
1741                        }
1742                        handle.read_bytes_range(range).await
1743                    })
1744                });
1745                return Ok(FileHandle::lazy(len, read_fn));
1746            }
1747            Ok(handle)
1748        }
1749    }
1750
1751    #[async_trait::async_trait]
1752    impl DirectoryWriterTrait for VectorReadFailDirectory {
1753        async fn write(&self, path: &Path, data: &[u8]) -> std::io::Result<()> {
1754            self.inner.write(path, data).await
1755        }
1756
1757        async fn delete(&self, path: &Path) -> std::io::Result<()> {
1758            self.inner.delete(path).await
1759        }
1760
1761        async fn rename(&self, from: &Path, to: &Path) -> std::io::Result<()> {
1762            self.inner.rename(from, to).await
1763        }
1764
1765        async fn sync(&self) -> std::io::Result<()> {
1766            self.inner.sync().await
1767        }
1768
1769        async fn streaming_writer(
1770            &self,
1771            path: &Path,
1772        ) -> std::io::Result<Box<dyn crate::directories::StreamingWriter>> {
1773            self.inner.streaming_writer(path).await
1774        }
1775    }
1776
1777    /// A failed read from the flat staging generation must abort training
1778    /// before artifacts or Built metadata are published.
1779    #[tokio::test]
1780    async fn build_propagates_vector_read_errors_without_publishing_artifacts() {
1781        let mut sb = SchemaBuilder::default();
1782        let embedding = sb.add_dense_vector_field_with_config(
1783            "embedding",
1784            true,
1785            true,
1786            DenseVectorConfig::ivf_tq(READ_FAIL_DIM, Some(1), 1),
1787        );
1788        let schema = sb.build();
1789
1790        let dir = VectorReadFailDirectory::default();
1791        let config = IndexConfig {
1792            merge_policy: Box::new(crate::merge::NoMergePolicy),
1793            num_indexing_threads: 1,
1794            ..Default::default()
1795        };
1796        let mut writer = IndexWriter::create(dir.clone(), schema, config)
1797            .await
1798            .unwrap();
1799        for i in 0..READ_FAIL_DOCS {
1800            let mut doc = Document::new();
1801            doc.add_dense_vector(embedding, vec![i as f32 + 1.0; READ_FAIL_DIM]);
1802            writer.add_document(doc).unwrap();
1803        }
1804        writer.commit().await.unwrap();
1805        dir.fail_vector_reads.store(true, Ordering::SeqCst);
1806        let error = writer
1807            .build_vector_index()
1808            .await
1809            .expect_err("failed sample collection must fail the build")
1810            .to_string();
1811        assert!(
1812            error.contains("injected vector data read failure"),
1813            "{error}"
1814        );
1815
1816        assert!(
1817            !writer
1818                .segment_manager
1819                .read_metadata(|meta| meta.is_field_built(embedding.0))
1820                .await,
1821            "a failed build must not publish Built metadata"
1822        );
1823        assert!(
1824            writer.segment_manager.trained().is_none(),
1825            "a failed build must not publish trained artifacts"
1826        );
1827    }
1828
1829    #[tokio::test]
1830    async fn retrain_read_failure_keeps_the_complete_published_generation() {
1831        let mut sb = SchemaBuilder::default();
1832        let embedding = sb.add_dense_vector_field_with_config(
1833            "embedding",
1834            true,
1835            true,
1836            DenseVectorConfig::ivf_tq(READ_FAIL_DIM, Some(1), 1),
1837        );
1838        let schema = sb.build();
1839        let dir = VectorReadFailDirectory::default();
1840        let config = IndexConfig {
1841            merge_policy: Box::new(crate::merge::NoMergePolicy),
1842            num_indexing_threads: 1,
1843            ..Default::default()
1844        };
1845        let mut writer = IndexWriter::create(dir.clone(), schema, config)
1846            .await
1847            .unwrap();
1848        for i in 0..READ_FAIL_DOCS {
1849            let mut doc = Document::new();
1850            doc.add_dense_vector(embedding, vec![i as f32 + 1.0; READ_FAIL_DIM]);
1851            writer.add_document(doc).unwrap();
1852        }
1853        writer.commit().await.unwrap();
1854        writer.build_vector_index().await.unwrap();
1855
1856        let old_ids = writer.segment_manager.get_segment_ids().await;
1857        let old_meta = writer
1858            .segment_manager
1859            .read_metadata(|metadata| metadata.get_field_meta(embedding.0).cloned())
1860            .await
1861            .unwrap();
1862        let old_version = writer.segment_manager.trained().unwrap().centroids[&embedding.0].version;
1863
1864        dir.fail_all_vector_reads.store(true, Ordering::SeqCst);
1865        let error = writer
1866            .retrain_vector_index()
1867            .await
1868            .expect_err("failed sample collection must abort the retrain")
1869            .to_string();
1870        assert!(
1871            error.contains("injected vector data read failure"),
1872            "{error}"
1873        );
1874        assert_eq!(writer.segment_manager.get_segment_ids().await, old_ids);
1875        assert_eq!(
1876            writer
1877                .segment_manager
1878                .read_metadata(|metadata| metadata
1879                    .get_field_meta(embedding.0)
1880                    .map(|field| (field.centroids_file.clone(), field.codebook_file.clone())))
1881                .await,
1882            Some((old_meta.centroids_file, old_meta.codebook_file)),
1883        );
1884        assert_eq!(
1885            writer.segment_manager.trained().unwrap().centroids[&embedding.0].version,
1886            old_version,
1887        );
1888    }
1889}