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