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 ones_codes = 0usize;
998        let mut global_offset = 0usize;
999        let mut cursor = 0usize;
1000        let field_ids = [field.0];
1001
1002        for id_str in segment_ids {
1003            let segment_id = SegmentId::from_hex(id_str)
1004                .ok_or_else(|| Error::Corruption(format!("Invalid segment ID: {id_str}")))?;
1005            let vectors = self.load_training_vectors(segment_id, &field_ids).await?;
1006
1007            let Some(lazy_flat) = vectors.flat_vectors.get(&field.0) else {
1008                continue;
1009            };
1010            let base = global_offset;
1011            let end = base.checked_add(lazy_flat.num_vectors).ok_or_else(|| {
1012                Error::Corruption(format!("vector offset overflows for field {}", field.0))
1013            })?;
1014            global_offset = end;
1015            let first = cursor;
1016            while cursor < ordinals.len() && ordinals[cursor] < end {
1017                cursor += 1;
1018            }
1019            let selected = &ordinals[first..cursor];
1020            let mut run_start = 0;
1021            while run_start < selected.len() {
1022                let mut run_end = run_start + 1;
1023                while run_end < selected.len() {
1024                    let selected_count = run_end - run_start + 1;
1025                    let span = selected[run_end] - selected[run_start] + 1;
1026                    if span > max_read_vectors
1027                        || span > selected_count.saturating_mul(MAX_SAMPLE_READ_AMPLIFICATION)
1028                    {
1029                        break;
1030                    }
1031                    run_end += 1;
1032                }
1033                let local_start = selected[run_start] - base;
1034                let read_len = selected[run_end - 1] - selected[run_start] + 1;
1035                let bytes = lazy_flat
1036                    .read_vectors_batch(local_start, read_len)
1037                    .await
1038                    .map_err(crate::Error::Io)?;
1039                match &mut sample {
1040                    TrainingSample::Binary(codes) => {
1041                        let expected = read_len.checked_mul(bytes_per_sample).ok_or_else(|| {
1042                            Error::Corruption("binary sample read size overflows".into())
1043                        })?;
1044                        if bytes.len() != expected {
1045                            return Err(Error::Corruption(format!(
1046                                "binary sample read returned {} bytes, expected {expected}",
1047                                bytes.len(),
1048                            )));
1049                        }
1050                        for &ordinal in &selected[run_start..run_end] {
1051                            let relative = ordinal - selected[run_start];
1052                            let offset = relative * bytes_per_sample;
1053                            let code = &bytes.as_slice()[offset..offset + bytes_per_sample];
1054                            // Degenerate constant codes are withheld from
1055                            // training: k-majority dedicates centroids to
1056                            // them, which only institutionalizes the producer
1057                            // bug. One production field turned ~30% of a 163k
1058                            // codebook into duplicate zero centroids; another
1059                            // trained centroid 0 to exactly 0xFF from two
1060                            // years of signbit-packed NaN vectors. (They are
1061                            // still *indexed* — payload/flat parity — just
1062                            // not trained on.)
1063                            if code.iter().all(|&byte| byte == 0) {
1064                                zero_codes += 1;
1065                                continue;
1066                            }
1067                            if code.iter().all(|&byte| byte == 0xff) {
1068                                ones_codes += 1;
1069                                continue;
1070                            }
1071                            codes.extend_from_slice(code);
1072                        }
1073                    }
1074                    TrainingSample::Float(values) => {
1075                        let dim = lazy_flat.dim;
1076                        let float_count = read_len.checked_mul(dim).ok_or_else(|| {
1077                            Error::Corruption("float sample read size overflows".into())
1078                        })?;
1079                        let mut decoded = vec![0.0; float_count];
1080                        crate::segment::dequantize_raw(
1081                            bytes.as_slice(),
1082                            lazy_flat.quantization,
1083                            decoded.len(),
1084                            &mut decoded,
1085                        )
1086                        .map_err(crate::Error::Io)?;
1087                        for &ordinal in &selected[run_start..run_end] {
1088                            let relative = ordinal - selected[run_start];
1089                            let offset = relative * dim;
1090                            values.extend_from_slice(&decoded[offset..offset + dim]);
1091                        }
1092                    }
1093                }
1094                run_start = run_end;
1095            }
1096        }
1097
1098        let collected = sample.len(config.dim());
1099        // Coverage is checked against what was *selected*; withheld degenerate
1100        // codes are subtracted explicitly so a real traversal bug still trips.
1101        if global_offset != total || cursor != take || collected + zero_codes + ones_codes != take {
1102            return Err(Error::Corruption(format!(
1103                "training sample coverage mismatch for field {}: counted={total}, traversed={global_offset}, selected={cursor}, collected={collected}, zero={zero_codes}, ones={ones_codes}",
1104                field.0,
1105            )));
1106        }
1107        if zero_codes > 0 {
1108            log::warn!(
1109                "[vector_training] index={} field={}: {zero_codes} of {take} sampled vectors \
1110                 ({:.1}%) are all-zero and were excluded from training — they cannot be assigned \
1111                 to any leaf, so training on them only wastes centroids",
1112                self.schema.index_label(),
1113                field.0,
1114                100.0 * zero_codes as f64 / take.max(1) as f64,
1115            );
1116        }
1117        if ones_codes > 0 {
1118            log::warn!(
1119                "[vector_training] index={} field={}: {ones_codes} of {take} sampled vectors \
1120                 ({:.1}%) are all-ones and were excluded from training — training on the \
1121                 saturated constant only dedicates centroids to a producer bug",
1122                self.schema.index_label(),
1123                field.0,
1124                100.0 * ones_codes as f64 / take.max(1) as f64,
1125            );
1126        }
1127        if collected == 0 {
1128            log::warn!(
1129                "[vector_training] index={} field={}: every sampled vector is degenerate \
1130                 (all-zero or all-ones); skipping ANN training for this field",
1131                self.schema.index_label(),
1132                field.0,
1133            );
1134            return Ok(None);
1135        }
1136        if collected < total {
1137            log::info!(
1138                "[vector_training] sampled {} / {} dense vectors: index={} field={} (max {} vectors / {} resident)",
1139                collected,
1140                total,
1141                self.schema.index_label(),
1142                field.0,
1143                self.config.vector_training_max_samples,
1144                crate::format_bytes(self.config.vector_training_memory_bytes as u64),
1145            );
1146        }
1147        Ok(Some(sample))
1148    }
1149
1150    /// Train one field. Called from the shared bounded Rayon pool, so fields
1151    /// and each field's internal clustering work compose without extra pools.
1152    fn train_field_model(
1153        field: Field,
1154        config: &IvfFieldConfig,
1155        sample: &mut TrainingSample,
1156        corpus_count: usize,
1157        artifact_generation: &str,
1158        index_label: &str,
1159    ) -> Result<TrainedFieldModel> {
1160        let field_id = field.0;
1161        let dim = config.dim();
1162        let sample_count = sample.len(dim);
1163        if sample_count == 0 || corpus_count == 0 {
1164            return Err(Error::Internal(format!(
1165                "empty training sample for non-empty field {field_id}"
1166            )));
1167        }
1168        let num_clusters = effective_field_num_clusters(config, corpus_count, sample_count)?;
1169
1170        log::info!(
1171            "[vector_training] training model: index={} field={} with {} sampled / {} total vectors, {} clusters (dim={})",
1172            index_label,
1173            field_id,
1174            sample_count,
1175            corpus_count,
1176            num_clusters,
1177            dim,
1178        );
1179
1180        let centroids_filename =
1181            format!("{VECTOR_ARTIFACT_PREFIX}{artifact_generation}_field_{field_id}_centroids.bin");
1182
1183        let artifacts = match (config, sample) {
1184            (IvfFieldConfig::Float(config), TrainingSample::Float(values))
1185                if config.index_type == VectorIndexType::IvfTq =>
1186            {
1187                values
1188                    .chunks_exact_mut(dim)
1189                    .for_each(crate::structures::vector::ivf::routing::normalize_cosine_in_place);
1190                let candidate_validation_count =
1191                    validation_sample_count(sample_count, num_clusters, dim);
1192                let candidate_training_count = sample_count - candidate_validation_count;
1193                let seeds = model_selection_seeds(
1194                    candidate_training_count,
1195                    num_clusters,
1196                    dim,
1197                    candidate_validation_count > 0,
1198                );
1199                let split_seed =
1200                    MODEL_SELECTION_SEEDS[0] ^ u64::from(field_id) ^ corpus_count as u64;
1201                let split = if seeds.len() > 1 {
1202                    partition_contiguous_holdout_suffix(
1203                        values.as_mut_slice(),
1204                        dim,
1205                        candidate_validation_count,
1206                        split_seed,
1207                    )
1208                } else {
1209                    values.len()
1210                };
1211                let (training_values, validation) = values.as_slice().split_at(split);
1212                let training_count = training_values.len() / dim;
1213                let validation_count = validation.len() / dim;
1214                if seeds.len() > 1 {
1215                    log::info!(
1216                        "[vector_training] model selection: index={index_label} field={field_id}, {} training + {} held-out vectors, {} deterministic centroid seed(s)",
1217                        training_count,
1218                        validation_count,
1219                        seeds.len(),
1220                    );
1221                } else {
1222                    log::info!(
1223                        "[vector_training] model selection: index={index_label} field={field_id}, all {} sampled vectors with one deterministic \
1224                         centroid seed; model-selection holdout disabled",
1225                        training_count,
1226                    );
1227                }
1228
1229                let mut base_config = crate::structures::CoarseConfig::new(dim, num_clusters)
1230                    .with_routing(config.ivf_routing);
1231                if let Some(soar) = config.soar.clone() {
1232                    base_config = base_config.with_soar(soar);
1233                }
1234
1235                let mut selected: Option<(
1236                    crate::structures::CoarseCentroids,
1237                    Option<FloatBuildQuality>,
1238                    u64,
1239                )> = None;
1240                for &seed in seeds {
1241                    let candidate = crate::structures::CoarseCentroids::train_contiguous(
1242                        &base_config.clone().with_seed(seed),
1243                        training_values,
1244                        training_count,
1245                        index_label,
1246                    );
1247                    let quality =
1248                        evaluate_float_build_quality(&candidate, validation, config.ivf_routing);
1249                    if let Some(quality) = quality {
1250                        log::info!(
1251                            "[vector_training] IVF candidate: index={index_label} field={field_id} seed={seed}, objective={:.6}, \
1252                             exact/routed_mean_distortion={:.6}/{:.6}, router_recall@1={:.4}, \
1253                             construction_postings/vector={:.3}, \
1254                             residual_scale[p50/p95/p99]={:.4}/{:.4}/{:.4}, \
1255                             construction_occupancy[p95/p99/max/empty]={}/{}/{}/{}",
1256                            quality.objective,
1257                            quality.mean_exact_distortion,
1258                            quality.mean_routed_distortion,
1259                            quality.router_recall_at_1,
1260                            quality.mean_construction_assignments,
1261                            quality.residual_p50,
1262                            quality.residual_p95,
1263                            quality.residual_p99,
1264                            quality.occupancy.p95,
1265                            quality.occupancy.p99,
1266                            quality.occupancy.max,
1267                            quality.occupancy.empty,
1268                        );
1269                    }
1270                    let replace = selected.as_ref().is_none_or(|(_, best, _)| {
1271                        quality
1272                            .map(|quality| quality.objective)
1273                            .unwrap_or(f64::INFINITY)
1274                            .total_cmp(
1275                                &best
1276                                    .map(|quality| quality.objective)
1277                                    .unwrap_or(f64::INFINITY),
1278                            )
1279                            .is_lt()
1280                    });
1281                    if replace {
1282                        selected = Some((candidate, quality, seed));
1283                    }
1284                }
1285                let (mut centroids, quality, seed) =
1286                    selected.expect("the fixed centroid seed bank is non-empty");
1287                if let Some(quality) = quality {
1288                    log::info!(
1289                        "[vector_training] selected IVF seed: index={index_label} field={field_id} seed={seed}, held-out objective {:.6} \
1290                         (occupancy penalty {:.4})",
1291                        quality.objective,
1292                        quality.occupancy.penalty,
1293                    );
1294                } else {
1295                    log::info!(
1296                        "[vector_training] selected IVF seed: index={index_label} field={field_id} seed={seed}, without a model-selection \
1297                         holdout",
1298                    );
1299                }
1300                centroids.version =
1301                    crate::structures::mark_ivf_tq_cosine_generation(centroids.version);
1302                TrainedFieldArtifacts::FloatCentroids(centroids)
1303            }
1304            (IvfFieldConfig::Binary(config), TrainingSample::Binary(codes)) => {
1305                let byte_len = dim.div_ceil(8);
1306                let training_count = codes.len() / byte_len;
1307                let mut binary_config = crate::structures::BinaryIvfConfig::new(dim, num_clusters);
1308                binary_config.max_train_samples = training_count;
1309                binary_config.routing = config.ivf_routing;
1310                let quantizer = crate::structures::BinaryCoarseQuantizer::train(
1311                    binary_config,
1312                    codes,
1313                    training_count,
1314                    index_label,
1315                )
1316                .map_err(Error::Io)?;
1317                TrainedFieldArtifacts::Binary(quantizer)
1318            }
1319            _ => {
1320                return Err(Error::Internal(format!(
1321                    "training sample kind does not match field {field_id}"
1322                )));
1323            }
1324        };
1325
1326        let actual_num_clusters = match &artifacts {
1327            TrainedFieldArtifacts::FloatCentroids(centroids) => centroids.num_clusters as usize,
1328            TrainedFieldArtifacts::Binary(quantizer) => quantizer.num_clusters as usize,
1329        };
1330        Ok(TrainedFieldModel {
1331            update: TrainedFieldUpdate {
1332                field_id,
1333                index_type: config.index_type(),
1334                vector_count: corpus_count,
1335                num_clusters: actual_num_clusters,
1336                centroids_file: centroids_filename,
1337                codebook_file: None,
1338            },
1339            artifacts,
1340        })
1341    }
1342
1343    async fn save_trained_field(&self, model: TrainedFieldModel) -> Result<TrainedFieldUpdate> {
1344        let TrainedFieldModel { update, artifacts } = model;
1345        match artifacts {
1346            TrainedFieldArtifacts::FloatCentroids(centroids) => {
1347                self.save_trained_artifact(&centroids, &update.centroids_file)
1348                    .await?;
1349                log::info!(
1350                    "[vector_training] saved IVF-TQ coarse artifact: index={} field={} ({} clusters; leaf codec is derived)",
1351                    self.schema.index_label(),
1352                    update.field_id,
1353                    centroids.num_clusters,
1354                );
1355            }
1356            TrainedFieldArtifacts::Binary(quantizer) => {
1357                self.save_trained_artifact(&quantizer, &update.centroids_file)
1358                    .await?;
1359                log::info!(
1360                    "[vector_training] saved binary IVF artifact: index={} field={} ({} clusters)",
1361                    self.schema.index_label(),
1362                    update.field_id,
1363                    quantizer.num_clusters,
1364                );
1365            }
1366        }
1367        Ok(update)
1368    }
1369
1370    /// Serialize a trained structure to bincode and save to an index-level file.
1371    async fn save_trained_artifact(
1372        &self,
1373        artifact: &impl serde::Serialize,
1374        filename: &str,
1375    ) -> Result<()> {
1376        let temp_filename = format!("{filename}.tmp");
1377        let temp_path = std::path::Path::new(&temp_filename);
1378        let final_path = std::path::Path::new(filename);
1379        let mut writer = self.directory.streaming_writer(temp_path).await?;
1380        let encode_result = {
1381            let mut limited = SizeLimitedWriter::new(
1382                writer.as_mut(),
1383                super::metadata::MAX_TRAINED_ARTIFACT_BYTES,
1384            );
1385            bincode::serde::encode_into_std_write(
1386                artifact,
1387                &mut limited,
1388                bincode::config::standard(),
1389            )
1390        };
1391        if let Err(error) = encode_result {
1392            drop(writer);
1393            let _ = self.directory.delete(temp_path).await;
1394            return Err(Error::Serialization(format!(
1395                "failed to serialize trained artifact '{filename}': {error}"
1396            )));
1397        }
1398        if let Err(error) = writer.finish() {
1399            let _ = self.directory.delete(temp_path).await;
1400            return Err(Error::Io(error));
1401        }
1402        if let Err(error) = self.directory.rename(temp_path, final_path).await {
1403            let _ = self.directory.delete(temp_path).await;
1404            return Err(Error::Io(error));
1405        }
1406        self.directory.sync().await?;
1407        Ok(())
1408    }
1409}
1410
1411#[cfg(test)]
1412mod tests {
1413    use super::*;
1414
1415    fn ivf_config(num_clusters: Option<usize>) -> DenseVectorConfig {
1416        DenseVectorConfig::ivf_tq(8, num_clusters, 4)
1417    }
1418
1419    #[test]
1420    fn effective_clusters_follow_corpus_heuristic_but_fit_sample() {
1421        let config = ivf_config(None);
1422
1423        assert_eq!(
1424            effective_ivf_num_clusters(&config, 1_000_000, 73).unwrap(),
1425            16
1426        );
1427        assert_eq!(
1428            effective_ivf_num_clusters(&config, 10_000, 1_000).unwrap(),
1429            25
1430        );
1431    }
1432
1433    #[test]
1434    fn effective_clusters_clamp_explicit_value_to_sample() {
1435        let config = ivf_config(Some(256));
1436        assert_eq!(
1437            effective_ivf_num_clusters(&config, 1_000_000, 17).unwrap(),
1438            17
1439        );
1440    }
1441
1442    #[test]
1443    fn effective_clusters_reject_invalid_explicit_bounds() {
1444        let zero = effective_ivf_num_clusters(&ivf_config(Some(0)), 10_000, 100)
1445            .unwrap_err()
1446            .to_string();
1447        assert!(zero.contains("at least 1"));
1448
1449        let too_many =
1450            effective_ivf_num_clusters(&ivf_config(Some(MAX_IVF_CLUSTERS + 1)), 10_000, 100)
1451                .unwrap_err()
1452                .to_string();
1453        assert!(too_many.contains("must not exceed 1048576"));
1454    }
1455
1456    #[test]
1457    fn effective_clusters_reject_empty_training_sample() {
1458        let error = effective_ivf_num_clusters(&ivf_config(None), 10_000, 0)
1459            .unwrap_err()
1460            .to_string();
1461        assert!(error.contains("without sample vectors"));
1462    }
1463
1464    #[test]
1465    fn training_sample_limit_honors_both_cli_bounds() {
1466        assert_eq!(training_sample_limit(10_000_000, 4_096, 4).unwrap(), 1_024);
1467        assert_eq!(training_sample_limit(100, 4_096, 4).unwrap(), 100);
1468        let error = training_sample_limit(100, 3, 4).unwrap_err().to_string();
1469        assert!(error.contains("cannot hold one"), "{error}");
1470    }
1471
1472    #[test]
1473    fn final_sample_is_selected_at_the_points_per_centroid_ceiling() {
1474        let config = IvfFieldConfig::Float(DenseVectorConfig::ivf_tq(8, Some(4), 1));
1475        assert_eq!(
1476            final_training_sample_count(&config, 10_000, 10_000).unwrap(),
1477            4 * COARSE_TRAINING_POINTS_PER_CENTROID,
1478        );
1479        assert_eq!(
1480            final_training_sample_count(&config, 10_000, 512).unwrap(),
1481            512,
1482        );
1483    }
1484
1485    #[test]
1486    fn holdout_preserves_the_training_points_per_centroid_floor() {
1487        assert_eq!(validation_sample_count(390, 10, 8), 0);
1488        assert_eq!(validation_sample_count(391, 10, 8), 1);
1489
1490        let config = IvfFieldConfig::Float(ivf_config(None));
1491        let sample_count = 1_000;
1492        let clusters = effective_field_num_clusters(&config, 10_000, sample_count).unwrap();
1493        let held_out = validation_sample_count(sample_count, clusters, config.dim());
1494        assert!(
1495            sample_count - held_out >= clusters.saturating_mul(MIN_TRAINING_POINTS_PER_CENTROID)
1496        );
1497    }
1498
1499    #[test]
1500    fn deterministic_point_sample_is_sorted_unique_and_repeatable() {
1501        let first = deterministic_sample_ordinals(10_000, 1_000, 7);
1502        let repeated = deterministic_sample_ordinals(10_000, 1_000, 7);
1503        let other_seed = deterministic_sample_ordinals(10_000, 1_000, 8);
1504        assert_eq!(first, repeated);
1505        assert_ne!(first, other_seed);
1506        assert_eq!(first.len(), 1_000);
1507        assert!(first.windows(2).all(|pair| pair[0] < pair[1]));
1508        assert!(first.iter().all(|&ordinal| ordinal < 10_000));
1509    }
1510
1511    #[test]
1512    fn model_selection_accounts_for_initialization_and_all_lloyd_passes() {
1513        assert_eq!(model_selection_seeds(1_000, 16, 8, true).len(), 2);
1514        assert_eq!(model_selection_seeds(1_000, 16, 8, false).len(), 1);
1515        // A single assignment pass is only 180M coordinate comparisons, but
1516        // two complete initialization/refinement candidates exceed the budget.
1517        assert_eq!(model_selection_seeds(1_800, 1_000, 100, true).len(), 1);
1518        assert_eq!(
1519            model_selection_seeds(MAX_MULTI_SEED_COORDINATE_WORK, 2, 1, true).len(),
1520            1,
1521        );
1522    }
1523
1524    #[test]
1525    fn flat_float_sample_partitions_a_deterministic_holdout_suffix() {
1526        let original: Vec<f32> = (0..20).map(|value| value as f32).collect();
1527        let mut first = original.clone();
1528        let mut repeated = original.clone();
1529        let validation_count = validation_sample_count(10, 2, 2);
1530        let first_split = partition_contiguous_holdout_suffix(&mut first, 2, validation_count, 11);
1531        let repeated_split =
1532            partition_contiguous_holdout_suffix(&mut repeated, 2, validation_count, 11);
1533
1534        assert_eq!(first, repeated);
1535        assert_eq!(first_split, repeated_split);
1536        assert_eq!(first_split, 18);
1537        assert_eq!(first.len(), original.len());
1538        assert_eq!(first[first_split..].len(), 2);
1539
1540        let mut first_components: Vec<u32> =
1541            first.chunks_exact(2).map(|row| row[0] as u32).collect();
1542        first_components.sort_unstable();
1543        assert_eq!(first_components, vec![0, 2, 4, 6, 8, 10, 12, 14, 16, 18]);
1544        assert!(first.chunks_exact(2).all(|row| row[1] == row[0] + 1.0));
1545    }
1546
1547    #[test]
1548    fn occupancy_report_exposes_tail_and_empty_cells() {
1549        let report = occupancy_quality(vec![0, 1, 2, 7], 10);
1550        assert_eq!(report.p95, 7);
1551        assert_eq!(report.p99, 7);
1552        assert_eq!(report.max, 7);
1553        assert_eq!(report.empty, 1);
1554        assert!(report.penalty > 0.0);
1555    }
1556
1557    #[test]
1558    fn model_selection_objective_prices_routed_distortion_excess() {
1559        let objective = float_model_selection_objective(2.0, 3.0, 0.1);
1560        assert!((objective - 3.2).abs() < f64::EPSILON);
1561        assert_eq!(float_model_selection_objective(2.0, 1.5, 0.0), 2.0);
1562    }
1563
1564    #[test]
1565    fn float_quality_reports_exact_query_router_recall() {
1566        let training = [0.0, 0.0, 0.1, 0.0, 10.0, 10.0, 10.1, 10.0];
1567        let config = crate::structures::CoarseConfig::new(2, 2);
1568        let centroids = crate::structures::CoarseCentroids::train_contiguous(
1569            &config,
1570            &training,
1571            4,
1572            "test-index",
1573        );
1574        for routing in [
1575            crate::dsl::IvfRoutingMode::Flat,
1576            crate::dsl::IvfRoutingMode::Auto,
1577        ] {
1578            let quality =
1579                evaluate_float_build_quality(&centroids, &[0.05, 0.0, 10.05, 10.0], routing)
1580                    .unwrap();
1581
1582            assert_eq!(quality.router_recall_at_1, 1.0);
1583            assert!(
1584                (quality.mean_exact_distortion - quality.mean_routed_distortion).abs()
1585                    < f64::EPSILON
1586            );
1587            assert_eq!(quality.mean_construction_assignments, 1.0);
1588            assert_eq!(quality.occupancy.empty, 0);
1589        }
1590    }
1591
1592    #[test]
1593    fn float_quality_counts_soar_secondary_postings_in_occupancy() {
1594        let training = [0.0, 0.0, 0.1, 0.0, 10.0, 10.0, 10.1, 10.0];
1595        let config = crate::structures::CoarseConfig::new(2, 2)
1596            .with_routing(crate::dsl::IvfRoutingMode::Flat)
1597            .with_soar(crate::structures::SoarConfig::full());
1598        let centroids = crate::structures::CoarseCentroids::train_contiguous(
1599            &config,
1600            &training,
1601            4,
1602            "test-index",
1603        );
1604        let quality = evaluate_float_build_quality(
1605            &centroids,
1606            &[0.05, 0.0, 10.05, 10.0],
1607            crate::dsl::IvfRoutingMode::Flat,
1608        )
1609        .unwrap();
1610
1611        assert_eq!(quality.mean_construction_assignments, 2.0);
1612        assert_eq!(quality.occupancy.empty, 0);
1613    }
1614
1615    #[test]
1616    fn artifact_writer_enforces_limit_without_writing_past_it() {
1617        let mut output = Vec::new();
1618        let mut writer = SizeLimitedWriter::new(&mut output, 3);
1619        writer.write_all(&[1, 2]).unwrap();
1620        let error = writer.write_all(&[3, 4]).unwrap_err().to_string();
1621        assert!(error.contains("3-byte safety limit"), "{error}");
1622        assert_eq!(output, vec![1, 2]);
1623    }
1624
1625    #[test]
1626    fn ivf_tq_training_marks_generation_normalizes_and_calibrates_default_soar() {
1627        let config = IvfFieldConfig::Float(DenseVectorConfig::ivf_tq(2, Some(1), 1));
1628        let mut sample = TrainingSample::Float(vec![3.0, 4.0, 30.0, 40.0, 300.0, 400.0]);
1629        let model = IndexWriter::<crate::directories::RamDirectory>::train_field_model(
1630            Field(3),
1631            &config,
1632            &mut sample,
1633            3,
1634            "test",
1635            "test-index",
1636        )
1637        .unwrap();
1638        let TrainedFieldArtifacts::FloatCentroids(centroids) = model.artifacts else {
1639            panic!("expected float IVF-TQ centroids");
1640        };
1641
1642        assert!(crate::structures::is_ivf_tq_cosine_generation(
1643            centroids.version
1644        ));
1645        assert!((centroids.centroids[0] - 0.6).abs() < 1e-6);
1646        assert!((centroids.centroids[1] - 0.8).abs() < 1e-6);
1647        let soar = centroids
1648            .soar_config
1649            .as_ref()
1650            .expect("default SOAR should propagate into the trained router");
1651        assert_eq!(soar.num_secondary, 1);
1652        assert!(soar.selective);
1653        assert!(
1654            soar.spill_threshold > 0.0,
1655            "the negative 30% target tag should be replaced by a calibrated threshold"
1656        );
1657        assert_eq!(soar.calibration_target(), None);
1658    }
1659
1660    #[test]
1661    fn explicitly_disabled_soar_stays_off_during_ivf_tq_training() {
1662        let config = IvfFieldConfig::Float(DenseVectorConfig::ivf_tq(2, Some(1), 1).without_soar());
1663        let mut sample = TrainingSample::Float(vec![3.0, 4.0, 30.0, 40.0, 300.0, 400.0]);
1664        let model = IndexWriter::<crate::directories::RamDirectory>::train_field_model(
1665            Field(3),
1666            &config,
1667            &mut sample,
1668            3,
1669            "test-no-soar",
1670            "test-index",
1671        )
1672        .unwrap();
1673        let TrainedFieldArtifacts::FloatCentroids(centroids) = model.artifacts else {
1674            panic!("expected float IVF-TQ centroids");
1675        };
1676
1677        assert!(centroids.soar_config.is_none());
1678    }
1679
1680    // ===== rebuild destructive-downgrade regression tests =====
1681
1682    use std::path::Path;
1683    use std::sync::atomic::{AtomicBool, Ordering};
1684
1685    use crate::directories::{
1686        Directory, DirectoryWriter as DirectoryWriterTrait, FileHandle, RamDirectory, RangeReadFn,
1687    };
1688    use crate::dsl::{Document, SchemaBuilder};
1689    use crate::index::{IndexConfig, IndexWriter};
1690
1691    const READ_FAIL_DOCS: usize = 5;
1692    const READ_FAIL_DIM: usize = 4;
1693    /// Flat entry layout of a single-field, flat-only `.vectors` file written
1694    /// by the segment builder (data-first format): header (16 bytes) + raw f32
1695    /// vectors + doc-id map + TOC + footer. Only the raw vector region is read
1696    /// by training collection; segment open touches the header, doc-id map,
1697    /// TOC, and footer, which all live outside this byte range.
1698    const VEC_REGION_START: u64 = 16;
1699    const VEC_REGION_END: u64 = VEC_REGION_START + (READ_FAIL_DOCS * READ_FAIL_DIM * 4) as u64;
1700
1701    /// RamDirectory wrapper whose `.vectors` handles fail range reads of the
1702    /// raw vector region while `fail_vector_reads` is armed. Segment open
1703    /// keeps succeeding, so exactly the training-collection batch reads fail —
1704    /// the I/O the rebuild path used to swallow with `if let Ok`.
1705    #[derive(Clone, Default)]
1706    struct VectorReadFailDirectory {
1707        inner: RamDirectory,
1708        fail_vector_reads: Arc<AtomicBool>,
1709        fail_all_vector_reads: Arc<AtomicBool>,
1710    }
1711
1712    #[async_trait::async_trait]
1713    impl Directory for VectorReadFailDirectory {
1714        async fn exists(&self, path: &Path) -> std::io::Result<bool> {
1715            self.inner.exists(path).await
1716        }
1717
1718        async fn file_size(&self, path: &Path) -> std::io::Result<u64> {
1719            self.inner.file_size(path).await
1720        }
1721
1722        async fn open_read(&self, path: &Path) -> std::io::Result<FileHandle> {
1723            self.inner.open_read(path).await
1724        }
1725
1726        async fn read_range(
1727            &self,
1728            path: &Path,
1729            range: std::ops::Range<u64>,
1730        ) -> std::io::Result<crate::directories::OwnedBytes> {
1731            self.inner.read_range(path, range).await
1732        }
1733
1734        async fn list_files(&self, prefix: &Path) -> std::io::Result<Vec<std::path::PathBuf>> {
1735            self.inner.list_files(prefix).await
1736        }
1737
1738        async fn open_lazy(&self, path: &Path) -> std::io::Result<FileHandle> {
1739            let handle = self.inner.open_lazy(path).await?;
1740            if path.extension().is_some_and(|ext| ext == "vectors") {
1741                let armed = Arc::clone(&self.fail_vector_reads);
1742                let fail_all = Arc::clone(&self.fail_all_vector_reads);
1743                let len = handle.len();
1744                let read_fn: RangeReadFn = Arc::new(move |range: std::ops::Range<u64>| {
1745                    let handle = handle.clone();
1746                    let armed = Arc::clone(&armed);
1747                    let fail_all = Arc::clone(&fail_all);
1748                    Box::pin(async move {
1749                        if fail_all.load(Ordering::SeqCst)
1750                            || (armed.load(Ordering::SeqCst)
1751                                && range.start >= VEC_REGION_START
1752                                && range.end <= VEC_REGION_END)
1753                        {
1754                            return Err(std::io::Error::other("injected vector data read failure"));
1755                        }
1756                        handle.read_bytes_range(range).await
1757                    })
1758                });
1759                return Ok(FileHandle::lazy(len, read_fn));
1760            }
1761            Ok(handle)
1762        }
1763    }
1764
1765    #[async_trait::async_trait]
1766    impl DirectoryWriterTrait for VectorReadFailDirectory {
1767        async fn write(&self, path: &Path, data: &[u8]) -> std::io::Result<()> {
1768            self.inner.write(path, data).await
1769        }
1770
1771        async fn delete(&self, path: &Path) -> std::io::Result<()> {
1772            self.inner.delete(path).await
1773        }
1774
1775        async fn rename(&self, from: &Path, to: &Path) -> std::io::Result<()> {
1776            self.inner.rename(from, to).await
1777        }
1778
1779        async fn sync(&self) -> std::io::Result<()> {
1780            self.inner.sync().await
1781        }
1782
1783        async fn streaming_writer(
1784            &self,
1785            path: &Path,
1786        ) -> std::io::Result<Box<dyn crate::directories::StreamingWriter>> {
1787            self.inner.streaming_writer(path).await
1788        }
1789    }
1790
1791    /// A failed read from the flat staging generation must abort training
1792    /// before artifacts or Built metadata are published.
1793    #[tokio::test]
1794    async fn build_propagates_vector_read_errors_without_publishing_artifacts() {
1795        let mut sb = SchemaBuilder::default();
1796        let embedding = sb.add_dense_vector_field_with_config(
1797            "embedding",
1798            true,
1799            true,
1800            DenseVectorConfig::ivf_tq(READ_FAIL_DIM, Some(1), 1),
1801        );
1802        let schema = sb.build();
1803
1804        let dir = VectorReadFailDirectory::default();
1805        let config = IndexConfig {
1806            merge_policy: Box::new(crate::merge::NoMergePolicy),
1807            num_indexing_threads: 1,
1808            ..Default::default()
1809        };
1810        let mut writer = IndexWriter::create(dir.clone(), schema, config)
1811            .await
1812            .unwrap();
1813        for i in 0..READ_FAIL_DOCS {
1814            let mut doc = Document::new();
1815            doc.add_dense_vector(embedding, vec![i as f32 + 1.0; READ_FAIL_DIM]);
1816            writer.add_document(doc).unwrap();
1817        }
1818        writer.commit().await.unwrap();
1819        dir.fail_vector_reads.store(true, Ordering::SeqCst);
1820        let error = writer
1821            .build_vector_index()
1822            .await
1823            .expect_err("failed sample collection must fail the build")
1824            .to_string();
1825        assert!(
1826            error.contains("injected vector data read failure"),
1827            "{error}"
1828        );
1829
1830        assert!(
1831            !writer
1832                .segment_manager
1833                .read_metadata(|meta| meta.is_field_built(embedding.0))
1834                .await,
1835            "a failed build must not publish Built metadata"
1836        );
1837        assert!(
1838            writer.segment_manager.trained().is_none(),
1839            "a failed build must not publish trained artifacts"
1840        );
1841    }
1842
1843    #[tokio::test]
1844    async fn retrain_read_failure_keeps_the_complete_published_generation() {
1845        let mut sb = SchemaBuilder::default();
1846        let embedding = sb.add_dense_vector_field_with_config(
1847            "embedding",
1848            true,
1849            true,
1850            DenseVectorConfig::ivf_tq(READ_FAIL_DIM, Some(1), 1),
1851        );
1852        let schema = sb.build();
1853        let dir = VectorReadFailDirectory::default();
1854        let config = IndexConfig {
1855            merge_policy: Box::new(crate::merge::NoMergePolicy),
1856            num_indexing_threads: 1,
1857            ..Default::default()
1858        };
1859        let mut writer = IndexWriter::create(dir.clone(), schema, config)
1860            .await
1861            .unwrap();
1862        for i in 0..READ_FAIL_DOCS {
1863            let mut doc = Document::new();
1864            doc.add_dense_vector(embedding, vec![i as f32 + 1.0; READ_FAIL_DIM]);
1865            writer.add_document(doc).unwrap();
1866        }
1867        writer.commit().await.unwrap();
1868        writer.build_vector_index().await.unwrap();
1869
1870        let old_ids = writer.segment_manager.get_segment_ids().await;
1871        let old_meta = writer
1872            .segment_manager
1873            .read_metadata(|metadata| metadata.get_field_meta(embedding.0).cloned())
1874            .await
1875            .unwrap();
1876        let old_version = writer.segment_manager.trained().unwrap().centroids[&embedding.0].version;
1877
1878        dir.fail_all_vector_reads.store(true, Ordering::SeqCst);
1879        let error = writer
1880            .retrain_vector_index()
1881            .await
1882            .expect_err("failed sample collection must abort the retrain")
1883            .to_string();
1884        assert!(
1885            error.contains("injected vector data read failure"),
1886            "{error}"
1887        );
1888        assert_eq!(writer.segment_manager.get_segment_ids().await, old_ids);
1889        assert_eq!(
1890            writer
1891                .segment_manager
1892                .read_metadata(|metadata| metadata
1893                    .get_field_meta(embedding.0)
1894                    .map(|field| (field.centroids_file.clone(), field.codebook_file.clone())))
1895                .await,
1896            Some((old_meta.centroids_file, old_meta.codebook_file)),
1897        );
1898        assert_eq!(
1899            writer.segment_manager.trained().unwrap().centroids[&embedding.0].version,
1900            old_version,
1901        );
1902    }
1903}