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