1use 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
22const MAX_IVF_CLUSTERS: usize = 1_048_576;
25const MIN_TRAINING_POINTS_PER_CENTROID: usize = 39;
28const COARSE_TRAINING_POINTS_PER_CENTROID: usize = 256;
31const MAX_SAMPLE_READ_BYTES: usize = 64 * 1024 * 1024;
34const MAX_SAMPLE_READ_AMPLIFICATION: usize = 4;
38const VALIDATION_SAMPLE_DENOMINATOR: usize = 10;
41const MAX_VALIDATION_SAMPLES: usize = 65_536;
42const MAX_VALIDATION_COORDINATE_WORK: usize = 512_000_000;
45const MODEL_SELECTION_SEEDS: [u64; 2] = [42, 0x9e37_79b9_7f4a_7c15];
49const MAX_MULTI_SEED_COORDINATE_WORK: usize = 4_000_000_000;
50const 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 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 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
154struct 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
270fn 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
286fn 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 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
402fn 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 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 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, ¢er)| {
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, ¢er)| {
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 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#[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 pub async fn build_vector_index(&self) -> Result<()> {
580 self.build_vector_generation(VectorGenerationMode::BuildMissing)
581 .await
582 }
583
584 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 drop(snapshot);
698 drop(artifact_update);
699
700 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 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 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 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 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 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 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 .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 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 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 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 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 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 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 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(¢roids, &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 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 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(¢roids, &[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 ¢roids,
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 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 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 #[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 #[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}