1use serde::{Deserialize, Serialize};
7
8use super::routing::{
9 HIERARCHICAL_TRAINING_THRESHOLD, HnswRoutingGraph, IvfProbePlan, IvfRoutingTopology,
10 allocate_child_clusters, effective_routing_mode, float_probe_fingerprint, routing_parent_count,
11 select_best_candidates, select_parent_beam, select_parent_beam_for_build,
12};
13use super::soar::{MultiAssignment, SoarConfig};
14use crate::dsl::IvfRoutingMode;
15
16const SOAR_LAMBDA: f32 = 1.0;
20const MAX_SELECTIVE_SECONDARY_TO_PRIMARY_DISTANCE_RATIO_SQ: f32 = 4.0;
28const BUILD_ASSIGNMENT_CANDIDATES: usize = 128;
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct CoarseConfig {
36 pub num_clusters: usize,
38 pub dim: usize,
40 pub max_iters: usize,
42 pub seed: u64,
44 pub soar: Option<SoarConfig>,
46 pub routing: IvfRoutingMode,
48}
49
50impl CoarseConfig {
51 pub fn new(dim: usize, num_clusters: usize) -> Self {
52 Self {
53 num_clusters,
54 dim,
55 max_iters: 25,
56 seed: 42,
57 soar: None,
58 routing: IvfRoutingMode::Auto,
59 }
60 }
61
62 pub fn with_soar(mut self, config: SoarConfig) -> Self {
63 self.soar = Some(config);
64 self
65 }
66
67 pub fn with_seed(mut self, seed: u64) -> Self {
68 self.seed = seed;
69 self
70 }
71
72 pub fn with_max_iters(mut self, iters: usize) -> Self {
73 self.max_iters = iters;
74 self
75 }
76
77 pub fn with_routing(mut self, routing: IvfRoutingMode) -> Self {
78 self.routing = routing;
79 self
80 }
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct CoarseCentroids {
86 pub num_clusters: u32,
88 pub dim: usize,
90 pub centroids: Vec<f32>,
92 pub version: u64,
94 pub soar_config: Option<SoarConfig>,
96 pub(crate) routing_index: Option<FloatCentroidRouter>,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub(crate) enum FloatCentroidRouter {
102 TwoLevel {
103 parent_centroids: Vec<f32>,
104 topology: IvfRoutingTopology,
105 },
106 Hnsw(HnswRoutingGraph),
107}
108
109thread_local! {
110 static CENTROID_SCORE_SCRATCH: std::cell::RefCell<Vec<(u32, f32)>> =
114 const { std::cell::RefCell::new(Vec::new()) };
115}
116
117struct FlatClusterMemberships {
118 offsets: Vec<usize>,
119 members: Vec<usize>,
120}
121
122impl FlatClusterMemberships {
123 fn cluster(&self, cluster: usize) -> &[usize] {
124 &self.members[self.offsets[cluster]..self.offsets[cluster + 1]]
125 }
126}
127
128impl CoarseCentroids {
129 pub fn train(config: &CoarseConfig, vectors: &[Vec<f32>], index_label: &str) -> Self {
133 assert!(!vectors.is_empty(), "Cannot train on empty vector set");
134 assert!(config.num_clusters > 0, "Need at least 1 cluster");
135 assert!(vectors.iter().all(|vector| vector.len() == config.dim));
136
137 let flat = vectors
141 .iter()
142 .flat_map(|vector| vector.iter().copied())
143 .collect::<Vec<_>>();
144 Self::train_contiguous(config, &flat, vectors.len(), index_label)
145 }
146
147 pub(crate) fn train_contiguous(
149 config: &CoarseConfig,
150 vectors: &[f32],
151 vector_count: usize,
152 index_label: &str,
153 ) -> Self {
154 assert!(vector_count > 0, "Cannot train on empty vector set");
155 assert!(config.num_clusters > 0, "Need at least 1 cluster");
156 assert_eq!(vectors.len(), vector_count.saturating_mul(config.dim));
157
158 let actual_clusters = config.num_clusters.min(vector_count);
159 let (centroids, routing_index) =
160 match effective_routing_mode(config.routing, actual_clusters) {
161 IvfRoutingMode::TwoLevel => {
162 let (leaves, router) =
163 Self::train_hierarchical(config, vectors, vector_count, actual_clusters);
164 (leaves, Some(router))
165 }
166 IvfRoutingMode::Hnsw => {
167 let leaves = if actual_clusters >= HIERARCHICAL_TRAINING_THRESHOLD {
168 Self::train_hierarchical(config, vectors, vector_count, actual_clusters).0
169 } else {
170 Self::train_flat(config, vectors, vector_count, actual_clusters)
171 };
172 let graph = HnswRoutingGraph::build(
173 actual_clusters,
174 |left, right| {
175 let left = left as usize * config.dim;
176 let right = right as usize * config.dim;
177 squared_l2(
178 &leaves[left..left + config.dim],
179 &leaves[right..right + config.dim],
180 )
181 },
182 config.seed,
183 index_label,
184 );
185 (leaves, Some(FloatCentroidRouter::Hnsw(graph)))
186 }
187 IvfRoutingMode::Flat | IvfRoutingMode::Auto => (
188 Self::train_flat(config, vectors, vector_count, actual_clusters),
189 None,
190 ),
191 };
192
193 let version = std::time::SystemTime::now()
194 .duration_since(std::time::UNIX_EPOCH)
195 .unwrap_or_default()
196 .as_nanos() as u64;
197
198 let mut soar_config = config.soar.clone();
199 if let Some(soar) = &mut soar_config
200 && soar.num_secondary > 1
201 {
202 log::warn!(
203 "SOAR currently implements the published primary + one-secondary objective; \
204 clamping {} requested secondary assignments to one",
205 soar.num_secondary,
206 );
207 soar.num_secondary = 1;
208 }
209 let calibration_target = soar_config
210 .as_ref()
211 .and_then(SoarConfig::calibration_target);
212 let mut trained = Self {
213 num_clusters: actual_clusters as u32,
214 dim: config.dim,
215 centroids,
216 version,
217 soar_config: None,
220 routing_index,
221 };
222 if let Some(target) = calibration_target {
223 let threshold = trained.calibrate_selective_spill_threshold(
224 vectors,
225 vector_count,
226 config.routing,
227 target,
228 );
229 if let Some(soar) = &mut soar_config {
230 soar.spill_threshold = threshold;
231 }
232 log::info!(
233 "Calibrated SOAR selective spilling to at most {:.1}% of the training sample \
234 (residual threshold {:.6})",
235 target * 100.0,
236 threshold,
237 );
238 }
239 trained.soar_config = soar_config;
240 trained
241 }
242
243 pub fn from_leaf_centroids(
251 dim: usize,
252 centroids: Vec<f32>,
253 routing: IvfRoutingMode,
254 seed: u64,
255 index_label: &str,
256 ) -> Self {
257 assert!(dim > 0, "centroid dimension must be positive");
258 assert!(
259 !centroids.is_empty() && centroids.len().is_multiple_of(dim),
260 "centroid matrix length {} is not a non-empty multiple of dim {dim}",
261 centroids.len()
262 );
263 let num_clusters = centroids.len() / dim;
264 let routing_index = match effective_routing_mode(routing, num_clusters) {
265 IvfRoutingMode::Flat | IvfRoutingMode::Auto => None,
266 IvfRoutingMode::Hnsw => Some(FloatCentroidRouter::Hnsw(HnswRoutingGraph::build(
267 num_clusters,
268 |left, right| {
269 let left = left as usize * dim;
270 let right = right as usize * dim;
271 squared_l2(¢roids[left..left + dim], ¢roids[right..right + dim])
272 },
273 seed,
274 index_label,
275 ))),
276 IvfRoutingMode::TwoLevel => panic!(
277 "two-level routing needs trained parent centroids; use CoarseCentroids::train"
278 ),
279 };
280 Self {
281 num_clusters: num_clusters as u32,
282 dim,
283 centroids,
284 version: seed,
285 soar_config: None,
286 routing_index,
287 }
288 }
289
290 fn calibrate_selective_spill_threshold(
291 &self,
292 vectors: &[f32],
293 vector_count: usize,
294 routing: IvfRoutingMode,
295 target_fraction: f32,
296 ) -> f32 {
297 let routing = effective_routing_mode(routing, self.num_clusters as usize);
301 let sample_limit = match routing {
302 IvfRoutingMode::Flat | IvfRoutingMode::Auto => {
303 let work_per_vector = (self.num_clusters as usize).saturating_mul(self.dim).max(1);
304 (64_000_000usize / work_per_vector).clamp(32, 4_096)
305 }
306 IvfRoutingMode::TwoLevel | IvfRoutingMode::Hnsw => 8_192,
307 }
308 .min(vector_count);
309 let mut residual_norms = Vec::with_capacity(sample_limit);
310 for sample in 0..sample_limit {
311 let vector_index = sample.saturating_mul(vector_count) / sample_limit;
312 let offset = vector_index * self.dim;
313 let vector = &vectors[offset..offset + self.dim];
314 let primary = self.assign_with_routing(vector, routing).primary_cluster;
315 residual_norms.push(squared_l2(vector, self.get_centroid(primary)).sqrt());
316 }
317 residual_norms.sort_unstable_by(f32::total_cmp);
318 if residual_norms.is_empty() {
319 return 0.0;
320 }
321 let spill_count = ((residual_norms.len() as f32 * target_fraction.clamp(0.0, 1.0)).round()
322 as usize)
323 .min(residual_norms.len());
324 if spill_count == 0 {
325 let largest = residual_norms.last().copied().unwrap_or(0.0);
326 return threshold_strictly_above(largest);
327 }
328 let boundary = residual_norms[residual_norms.len() - spill_count];
329 let at_or_above =
330 residual_norms.len() - residual_norms.partition_point(|&norm| norm < boundary);
331 if at_or_above > spill_count {
332 threshold_strictly_above(boundary)
337 } else {
338 boundary
339 }
340 }
341
342 fn train_flat(
343 config: &CoarseConfig,
344 vectors: &[f32],
345 vector_count: usize,
346 clusters: usize,
347 ) -> Vec<f32> {
348 Self::run_kmeans(config, vectors, vector_count, clusters).centroids
349 }
350
351 fn train_flat_with_memberships(
352 config: &CoarseConfig,
353 vectors: &[f32],
354 vector_count: usize,
355 clusters: usize,
356 ) -> (Vec<f32>, FlatClusterMemberships) {
357 let trained = Self::run_kmeans(config, vectors, vector_count, clusters);
358 (
359 trained.centroids,
360 FlatClusterMemberships {
361 offsets: trained.member_offsets,
362 members: trained.members,
363 },
364 )
365 }
366
367 fn run_kmeans(
368 config: &CoarseConfig,
369 vectors: &[f32],
370 vector_count: usize,
371 clusters: usize,
372 ) -> crate::structures::vector::kmeans::EuclideanKMeans {
373 crate::structures::vector::kmeans::train_euclidean_kmeans(
374 vectors,
375 vector_count,
376 config.dim,
377 clusters,
378 config.max_iters,
379 config.seed,
380 )
381 }
382
383 fn train_hierarchical(
384 config: &CoarseConfig,
385 vectors: &[f32],
386 vector_count: usize,
387 leaf_count: usize,
388 ) -> (Vec<f32>, FloatCentroidRouter) {
389 let parent_count = routing_parent_count(leaf_count).min(vector_count);
390 let mut parent_config = config.clone();
391 parent_config.routing = IvfRoutingMode::Flat;
392 parent_config.num_clusters = parent_count;
393 let (parent_centroids, groups) =
394 Self::train_flat_with_memberships(&parent_config, vectors, vector_count, parent_count);
395 let group_sizes: Vec<usize> = (0..parent_count)
396 .map(|parent| groups.cluster(parent).len())
397 .collect();
398 let child_counts = allocate_child_clusters(&group_sizes, leaf_count);
399 let mut leaves = Vec::with_capacity(leaf_count.saturating_mul(config.dim));
400 let mut children = vec![Vec::new(); parent_count];
401 let mut group_vectors = Vec::new();
402
403 for (parent, &child_count) in child_counts.iter().enumerate() {
404 if child_count == 0 {
405 continue;
406 }
407 let indices = groups.cluster(parent);
408 group_vectors.clear();
409 group_vectors.reserve(indices.len().saturating_mul(config.dim));
410 for &index in indices {
411 let offset = index * config.dim;
412 group_vectors.extend_from_slice(&vectors[offset..offset + config.dim]);
413 }
414 let mut child_config = config.clone();
415 child_config.routing = IvfRoutingMode::Flat;
416 child_config.num_clusters = child_count;
417 child_config.seed = config.seed ^ (parent as u64).wrapping_mul(0x9e37_79b9_7f4a_7c15);
418 let first_leaf = leaves.len() / config.dim;
419 leaves.extend_from_slice(&Self::train_flat(
420 &child_config,
421 &group_vectors,
422 indices.len(),
423 child_count,
424 ));
425 children[parent].extend((first_leaf..first_leaf + child_count).map(|leaf| leaf as u32));
426 }
427 debug_assert_eq!(leaves.len(), leaf_count * config.dim);
428
429 (
430 leaves,
431 FloatCentroidRouter::TwoLevel {
432 parent_centroids,
433 topology: IvfRoutingTopology::from_children(&children),
434 },
435 )
436 }
437
438 fn find_nearest_idx_static(vector: &[f32], centroids: &[f32], dim: usize) -> usize {
440 let mut best_idx = 0;
441 let mut best_dist = f32::MAX;
442
443 for (c, centroid) in centroids.chunks_exact(dim).enumerate() {
444 let dist = squared_l2(vector, centroid);
445 if dist < best_dist {
446 best_dist = dist;
447 best_idx = c;
448 }
449 }
450
451 best_idx
452 }
453
454 pub fn find_nearest(&self, vector: &[f32]) -> u32 {
456 Self::find_nearest_idx_static(vector, &self.centroids, self.dim) as u32
457 }
458
459 pub fn find_k_nearest(&self, vector: &[f32], k: usize) -> Vec<u32> {
461 self.flat_k_nearest_with_distances(vector, k, |distances| {
462 distances.iter().map(|&(c, _)| c).collect()
463 })
464 }
465
466 fn flat_k_nearest_with_distances<T>(
471 &self,
472 vector: &[f32],
473 k: usize,
474 finish: impl FnOnce(&[(u32, f32)]) -> T,
475 ) -> T {
476 CENTROID_SCORE_SCRATCH.with(|scratch| {
477 let mut distances = scratch.borrow_mut();
478 distances.clear();
479 distances.extend(
480 (0..self.num_clusters).map(|c| (c, squared_l2(vector, self.get_centroid(c)))),
481 );
482
483 if distances.len() > k {
485 distances.select_nth_unstable_by(k, |a, b| a.1.total_cmp(&b.1));
486 distances.truncate(k);
487 }
488 distances.sort_unstable_by(|a, b| a.1.total_cmp(&b.1));
489 finish(&distances)
490 })
491 }
492
493 pub fn probe(&self, vector: &[f32], k: usize, mode: IvfRoutingMode) -> IvfProbePlan {
498 let take = k.clamp(1, self.num_clusters as usize);
499 let clusters = match effective_routing_mode(mode, self.num_clusters as usize) {
500 IvfRoutingMode::Flat | IvfRoutingMode::Auto => self.find_k_nearest(vector, take),
501 IvfRoutingMode::TwoLevel => self.find_k_nearest_two_level(vector, take),
502 IvfRoutingMode::Hnsw => self.find_k_nearest_hnsw(vector, take),
503 };
504 IvfProbePlan::new(
505 self.version,
506 float_probe_fingerprint(vector, take, mode),
507 clusters,
508 )
509 }
510
511 pub fn validate_routing(&self, mode: IvfRoutingMode) -> Result<(), String> {
512 match effective_routing_mode(mode, self.num_clusters as usize) {
513 IvfRoutingMode::Flat | IvfRoutingMode::Auto => Ok(()),
514 IvfRoutingMode::TwoLevel => {
515 let Some(FloatCentroidRouter::TwoLevel {
516 parent_centroids,
517 topology,
518 }) = self.routing_index.as_ref()
519 else {
520 return Err(
521 "two-level IVF routing was requested but the global codebook has no matching router"
522 .to_string(),
523 );
524 };
525 let parent_count = topology.parent_count();
526 if parent_count == 0
527 || parent_centroids.len() != parent_count.saturating_mul(self.dim)
528 || !topology.validate(self.num_clusters as usize)
529 || parent_centroids.iter().any(|value| !value.is_finite())
530 {
531 return Err("invalid float two-level IVF routing index".to_string());
532 }
533 Ok(())
534 }
535 IvfRoutingMode::Hnsw => {
536 let Some(FloatCentroidRouter::Hnsw(graph)) = self.routing_index.as_ref() else {
537 return Err(
538 "HNSW IVF routing was requested but the global codebook has no HNSW graph"
539 .to_string(),
540 );
541 };
542 if !graph.validate(self.num_clusters as usize) {
543 return Err("invalid float HNSW routing graph".to_string());
544 }
545 Ok(())
546 }
547 }
548 }
549
550 fn find_k_nearest_two_level(&self, vector: &[f32], k: usize) -> Vec<u32> {
551 self.find_k_nearest_two_level_impl::<false>(vector, k)
552 }
553
554 fn find_k_nearest_two_level_for_build(&self, vector: &[f32], k: usize) -> Vec<u32> {
555 self.find_k_nearest_two_level_impl::<true>(vector, k)
556 }
557
558 fn find_k_nearest_two_level_impl<const FOR_BUILD: bool>(
559 &self,
560 vector: &[f32],
561 k: usize,
562 ) -> Vec<u32> {
563 let Some(FloatCentroidRouter::TwoLevel {
564 parent_centroids,
565 topology,
566 }) = self.routing_index.as_ref()
567 else {
568 return self.find_k_nearest(vector, k);
569 };
570 if topology.parent_count() <= 1 {
571 return self.find_k_nearest(vector, k);
572 }
573
574 let mut parent_scores = vec![0.0; topology.parent_count()];
575 for (parent_id, score) in parent_scores.iter_mut().enumerate() {
576 let offset = parent_id * self.dim;
577 *score = squared_l2(vector, &parent_centroids[offset..offset + self.dim]);
578 }
579 let parents = if FOR_BUILD {
580 select_parent_beam_for_build::<false>(&parent_scores, topology, k)
581 } else {
582 select_parent_beam::<false>(&parent_scores, topology, k)
583 };
584 let candidate_capacity = parents
585 .iter()
586 .map(|&parent| topology.children(parent as usize).len())
587 .sum();
588 let mut candidates = Vec::with_capacity(candidate_capacity);
589 for parent in parents {
590 for &leaf in topology.children(parent as usize) {
591 candidates.push((leaf, squared_l2(vector, self.get_centroid(leaf))));
592 }
593 }
594 select_best_candidates::<false>(&mut candidates, k)
595 }
596
597 fn find_k_nearest_hnsw(&self, vector: &[f32], k: usize) -> Vec<u32> {
598 let Some(FloatCentroidRouter::Hnsw(graph)) = self.routing_index.as_ref() else {
599 return self.find_k_nearest(vector, k);
600 };
601 graph.search(|leaf| squared_l2(vector, self.get_centroid(leaf)), k)
602 }
603
604 pub fn find_k_nearest_with_distances(&self, vector: &[f32], k: usize) -> Vec<(u32, f32)> {
606 self.flat_k_nearest_with_distances(vector, k, <[(u32, f32)]>::to_vec)
607 }
608
609 pub fn assign(&self, vector: &[f32]) -> MultiAssignment {
611 self.assign_with_routing(vector, IvfRoutingMode::Flat)
612 }
613
614 pub fn assign_with_routing(&self, vector: &[f32], routing: IvfRoutingMode) -> MultiAssignment {
618 if let Some(ref soar_config) = self.soar_config {
619 self.assign_with_soar_and_routing(vector, soar_config, routing)
620 } else {
621 let primary_cluster = match effective_routing_mode(routing, self.num_clusters as usize)
622 {
623 IvfRoutingMode::Hnsw => self.find_nearest_hnsw_for_build(vector),
624 IvfRoutingMode::TwoLevel => self
625 .find_k_nearest_two_level_for_build(
626 vector,
627 BUILD_ASSIGNMENT_CANDIDATES.min(self.num_clusters as usize),
628 )
629 .first()
630 .copied()
631 .unwrap_or(0),
632 IvfRoutingMode::Flat | IvfRoutingMode::Auto => self.find_nearest(vector),
633 };
634 MultiAssignment {
635 primary_cluster,
636 secondary_clusters: Vec::new(),
637 }
638 }
639 }
640
641 pub fn assign_with_soar(&self, vector: &[f32], config: &SoarConfig) -> MultiAssignment {
643 self.assign_with_soar_and_routing(vector, config, IvfRoutingMode::Flat)
644 }
645
646 fn assign_with_soar_and_routing(
647 &self,
648 vector: &[f32],
649 config: &SoarConfig,
650 routing: IvfRoutingMode,
651 ) -> MultiAssignment {
652 let num_secondary = config.num_secondary.min(1);
657 let candidate_budget =
661 soar_build_candidate_budget(num_secondary, self.num_clusters as usize);
662 let leaf_ids: Vec<u32> = match effective_routing_mode(routing, self.num_clusters as usize) {
663 IvfRoutingMode::TwoLevel => {
664 self.two_level_candidate_leaves_for_build(vector, candidate_budget)
665 }
666 IvfRoutingMode::Hnsw => self.find_k_nearest_hnsw_for_build(vector, candidate_budget),
667 IvfRoutingMode::Flat | IvfRoutingMode::Auto => (0..self.num_clusters).collect(),
668 };
669 let leaf_distances: Vec<(u32, f32)> = leaf_ids
672 .into_iter()
673 .map(|cluster| (cluster, squared_l2(vector, self.get_centroid(cluster))))
674 .collect();
675 let primary = leaf_distances
676 .iter()
677 .min_by(|left, right| scored_cluster_order(left, right))
678 .map(|&(cluster, _)| cluster)
679 .unwrap_or(0);
680 let primary_centroid = self.get_centroid(primary);
681
682 let residual: Vec<f32> = vector
684 .iter()
685 .zip(primary_centroid)
686 .map(|(v, c)| v - c)
687 .collect();
688
689 let residual_norm_sq = crate::structures::simd::norm_squared_f32(&residual);
690
691 if config.selective && residual_norm_sq < config.spill_threshold * config.spill_threshold {
693 return MultiAssignment {
694 primary_cluster: primary,
695 secondary_clusters: Vec::new(),
696 };
697 }
698
699 let mut candidates: Vec<(u32, f32)> = leaf_distances
707 .into_iter()
708 .filter(|&(cluster, secondary_residual_norm_sq)| {
709 cluster != primary
710 && (!config.selective
711 || secondary_residual_norm_sq
712 <= MAX_SELECTIVE_SECONDARY_TO_PRIMARY_DISTANCE_RATIO_SQ
713 * residual_norm_sq)
714 })
715 .map(|(cluster, secondary_residual_norm_sq)| {
716 (
717 cluster,
718 soar_secondary_loss(
719 vector,
720 self.get_centroid(cluster),
721 &residual,
722 residual_norm_sq,
723 secondary_residual_norm_sq,
724 ),
725 )
726 })
727 .collect();
728
729 let take = num_secondary.min(candidates.len());
732 if candidates.len() > take {
733 candidates.select_nth_unstable_by(take, scored_cluster_order);
734 candidates.truncate(take);
735 }
736 candidates.sort_unstable_by(scored_cluster_order);
737
738 MultiAssignment {
739 primary_cluster: primary,
740 secondary_clusters: candidates
741 .iter()
742 .take(num_secondary)
743 .map(|(c, _)| *c)
744 .collect(),
745 }
746 }
747
748 fn two_level_candidate_leaves_for_build(&self, vector: &[f32], k: usize) -> Vec<u32> {
749 let Some(FloatCentroidRouter::TwoLevel {
750 parent_centroids,
751 topology,
752 }) = self.routing_index.as_ref()
753 else {
754 return (0..self.num_clusters).collect();
755 };
756 let mut parent_scores = vec![0.0; topology.parent_count()];
757 for (parent_id, score) in parent_scores.iter_mut().enumerate() {
758 let offset = parent_id * self.dim;
759 *score = squared_l2(vector, &parent_centroids[offset..offset + self.dim]);
760 }
761 let parents = select_parent_beam_for_build::<false>(&parent_scores, topology, k);
762 let capacity = parents
763 .iter()
764 .map(|&parent| topology.children(parent as usize).len())
765 .sum();
766 let mut leaves = Vec::with_capacity(capacity);
767 for parent in parents {
768 leaves.extend_from_slice(topology.children(parent as usize));
769 }
770 leaves
771 }
772
773 fn find_k_nearest_hnsw_for_build(&self, vector: &[f32], k: usize) -> Vec<u32> {
774 let Some(FloatCentroidRouter::Hnsw(graph)) = self.routing_index.as_ref() else {
775 return self.find_k_nearest(vector, k);
776 };
777 graph.search_for_build(|leaf| squared_l2(vector, self.get_centroid(leaf)), k)
778 }
779
780 fn find_nearest_hnsw_for_build(&self, vector: &[f32]) -> u32 {
783 let Some(FloatCentroidRouter::Hnsw(graph)) = self.routing_index.as_ref() else {
784 return self.find_nearest(vector);
785 };
786 graph
787 .search_best_for_build(|leaf| squared_l2(vector, self.get_centroid(leaf)))
788 .unwrap_or(0)
789 }
790
791 pub fn get_centroid(&self, cluster_id: u32) -> &[f32] {
793 let offset = cluster_id as usize * self.dim;
794 &self.centroids[offset..offset + self.dim]
795 }
796
797 pub fn compute_residual(&self, vector: &[f32], cluster_id: u32) -> Vec<f32> {
799 let centroid = self.get_centroid(cluster_id);
800 vector.iter().zip(centroid).map(|(&v, &c)| v - c).collect()
801 }
802
803 pub fn size_bytes(&self) -> usize {
805 let routing_bytes = self
806 .routing_index
807 .as_ref()
808 .map_or(0, |router| match router {
809 FloatCentroidRouter::TwoLevel {
810 parent_centroids,
811 topology,
812 } => {
813 parent_centroids.len() * size_of::<f32>()
814 + topology.parent_count() * size_of::<u32>()
815 + self.num_clusters as usize * size_of::<u32>()
816 }
817 FloatCentroidRouter::Hnsw(graph) => graph.size_bytes(),
818 });
819 self.centroids.len() * size_of::<f32>() + routing_bytes + 64
820 }
821
822 #[cfg(feature = "native")]
825 pub(crate) fn visit_routing_regions(&self, visit: &mut dyn FnMut(&'static str, &[u8])) {
826 if let Some(router) = &self.routing_index {
827 match router {
828 FloatCentroidRouter::TwoLevel {
829 parent_centroids,
830 topology,
831 } => {
832 topology.visit_resident_regions(visit);
833 visit(
834 "float parent centroids",
835 super::routing::bytes_of_slice(parent_centroids),
836 );
837 }
838 FloatCentroidRouter::Hnsw(graph) => graph.visit_resident_regions(visit),
839 }
840 }
841 }
842
843 #[cfg(feature = "native")]
844 pub(crate) fn visit_leaf_centroid_region(&self, visit: &mut dyn FnMut(&'static str, &[u8])) {
845 visit(
846 "float leaf centroids",
847 super::routing::bytes_of_slice(&self.centroids),
848 );
849 }
850
851 pub fn to_bytes(&self) -> std::io::Result<Vec<u8>> {
853 bincode::serde::encode_to_vec(self, bincode::config::standard())
854 .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))
855 }
856}
857
858#[inline]
859fn squared_l2(left: &[f32], right: &[f32]) -> f32 {
860 crate::structures::simd::squared_l2_f32(left, right)
861}
862
863#[inline]
864fn threshold_strictly_above(value: f32) -> f32 {
865 if !value.is_finite() {
866 return f32::INFINITY;
867 }
868 let next = value + value.abs().max(1.0) * (4.0 * f32::EPSILON);
873 if next > value { next } else { f32::INFINITY }
874}
875
876#[inline]
877fn soar_build_candidate_budget(num_secondary: usize, num_clusters: usize) -> usize {
878 num_secondary
879 .saturating_add(1)
880 .saturating_mul(64)
881 .max(BUILD_ASSIGNMENT_CANDIDATES)
882 .min(num_clusters)
883}
884
885#[inline]
886fn soar_secondary_loss(
887 vector: &[f32],
888 secondary_centroid: &[f32],
889 primary_residual: &[f32],
890 primary_residual_norm_sq: f32,
891 secondary_residual_norm_sq: f32,
892) -> f32 {
893 let residual_dot = vector
894 .iter()
895 .zip(secondary_centroid)
896 .zip(primary_residual)
897 .fold(0.0f32, |acc, ((&value, ¢roid), &primary)| {
898 acc.algebraic_add(primary.algebraic_mul(value - centroid))
899 });
900
901 let projection_norm_sq = if primary_residual_norm_sq > 0.0 {
904 residual_dot * residual_dot / primary_residual_norm_sq
905 } else {
906 0.0
907 };
908 secondary_residual_norm_sq + SOAR_LAMBDA * projection_norm_sq
909}
910
911#[inline]
912fn scored_cluster_order(left: &(u32, f32), right: &(u32, f32)) -> std::cmp::Ordering {
913 left.1
914 .total_cmp(&right.1)
915 .then_with(|| left.0.cmp(&right.0))
916}
917
918#[cfg(test)]
919mod tests {
920 use super::*;
921 use rand::prelude::*;
922
923 #[test]
924 fn test_coarse_centroids_basic() {
925 let dim = 64;
926 let n = 1000;
927 let num_clusters = 16;
928
929 let mut rng = rand::rngs::StdRng::seed_from_u64(42);
930 let vectors: Vec<Vec<f32>> = (0..n)
931 .map(|_| (0..dim).map(|_| rng.random::<f32>() - 0.5).collect())
932 .collect();
933
934 let config = CoarseConfig::new(dim, num_clusters);
935 let centroids = CoarseCentroids::train(&config, &vectors, "test");
936
937 assert_eq!(centroids.num_clusters, num_clusters as u32);
938 assert_eq!(centroids.dim, dim);
939 }
940
941 #[test]
942 fn contiguous_training_matches_row_wrapper() {
943 let dim = 8;
944 let vectors: Vec<Vec<f32>> = (0..128)
945 .map(|row| {
946 (0..dim)
947 .map(|column| ((row * 31 + column * 17) % 101) as f32 / 101.0)
948 .collect()
949 })
950 .collect();
951 let flat = vectors.iter().flatten().copied().collect::<Vec<_>>();
952 let config = CoarseConfig::new(dim, 12)
953 .with_seed(91)
954 .with_routing(IvfRoutingMode::TwoLevel);
955
956 let rows = CoarseCentroids::train(&config, &vectors, "test");
957 let contiguous = CoarseCentroids::train_contiguous(&config, &flat, vectors.len(), "test");
958
959 assert_eq!(rows.centroids, contiguous.centroids);
960 assert_eq!(
961 bincode::serde::encode_to_vec(&rows.routing_index, bincode::config::standard())
962 .unwrap(),
963 bincode::serde::encode_to_vec(&contiguous.routing_index, bincode::config::standard())
964 .unwrap(),
965 );
966 }
967
968 #[test]
969 fn test_find_nearest() {
970 let dim = 32;
971 let n = 500;
972 let num_clusters = 8;
973
974 let mut rng = rand::rngs::StdRng::seed_from_u64(123);
975 let vectors: Vec<Vec<f32>> = (0..n)
976 .map(|_| (0..dim).map(|_| rng.random::<f32>()).collect())
977 .collect();
978
979 let config = CoarseConfig::new(dim, num_clusters);
980 let centroids = CoarseCentroids::train(&config, &vectors, "test");
981
982 for v in &vectors {
984 let cluster = centroids.find_nearest(v);
985 assert!(cluster < centroids.num_clusters);
986 }
987 }
988
989 #[test]
990 fn scaled_l2_probes_keep_distinct_cache_identities() {
991 let centroids = CoarseCentroids {
992 num_clusters: 2,
993 dim: 2,
994 centroids: vec![1.0, 0.0, 10.0, 0.0],
995 version: 7,
996 soar_config: None,
997 routing_index: None,
998 };
999
1000 let near = centroids.probe(&[1.0, 0.0], 1, IvfRoutingMode::Flat);
1001 let scaled = centroids.probe(&[100.0, 0.0], 1, IvfRoutingMode::Flat);
1002
1003 assert_eq!(&*near.cluster_ids, &[0]);
1004 assert_eq!(&*scaled.cluster_ids, &[1]);
1005 assert_ne!(near.request_fingerprint, scaled.request_fingerprint);
1006 }
1007
1008 #[test]
1009 fn test_soar_assignment() {
1010 let dim = 32;
1011 let n = 100;
1012 let num_clusters = 8;
1013
1014 let mut rng = rand::rngs::StdRng::seed_from_u64(456);
1015 let vectors: Vec<Vec<f32>> = (0..n)
1016 .map(|_| (0..dim).map(|_| rng.random::<f32>()).collect())
1017 .collect();
1018
1019 let soar_config = SoarConfig {
1020 num_secondary: 2,
1021 selective: false,
1022 spill_threshold: 0.0,
1023 };
1024 let config = CoarseConfig::new(dim, num_clusters).with_soar(soar_config);
1025 let centroids = CoarseCentroids::train(&config, &vectors, "test");
1026
1027 let assignment = centroids.assign(&vectors[0]);
1029 assert!(assignment.primary_cluster < centroids.num_clusters);
1030 assert_eq!(centroids.soar_config.as_ref().unwrap().num_secondary, 1);
1031 assert_eq!(assignment.secondary_clusters.len(), 1);
1032
1033 for &sec in &assignment.secondary_clusters {
1035 assert_ne!(sec, assignment.primary_cluster);
1036 }
1037 }
1038
1039 #[test]
1040 fn soar_loss_includes_distortion_and_normalized_projection() {
1041 let vector = [0.0, 0.0];
1042 let primary_residual = [2.0, 0.0];
1043 let secondary_centroid = [-3.0, -4.0];
1044
1045 let loss = soar_secondary_loss(&vector, &secondary_centroid, &primary_residual, 4.0, 25.0);
1048 assert!((loss - 34.0).abs() <= f32::EPSILON);
1049 }
1050
1051 #[test]
1052 fn soar_routing_keeps_an_oversampled_secondary_candidate_pool() {
1053 assert_eq!(soar_build_candidate_budget(1, 1_000), 128);
1054 assert_eq!(soar_build_candidate_budget(2, 1_000), 192);
1055 assert_eq!(soar_build_candidate_budget(8, 64), 64);
1056 }
1057
1058 #[test]
1059 fn two_level_build_assignment_checks_four_parents_for_primary_and_soar() {
1060 let leaves_per_parent = 512;
1061 let children: Vec<Vec<u32>> = (0..4)
1062 .map(|parent| {
1063 let first = parent * leaves_per_parent;
1064 (first..first + leaves_per_parent)
1065 .map(|leaf| leaf as u32)
1066 .collect()
1067 })
1068 .collect();
1069 let mut leaf_centroids = vec![1.0; 4 * leaves_per_parent];
1070 let best_leaf = 3 * leaves_per_parent;
1071 leaf_centroids[best_leaf] = 0.0;
1072 let centroids = CoarseCentroids {
1073 num_clusters: leaf_centroids.len() as u32,
1074 dim: 1,
1075 centroids: leaf_centroids,
1076 version: 1,
1077 soar_config: None,
1078 routing_index: Some(FloatCentroidRouter::TwoLevel {
1079 parent_centroids: vec![0.0, 10.0, 20.0, 30.0],
1080 topology: IvfRoutingTopology::from_children(&children),
1081 }),
1082 };
1083
1084 let query = [0.0];
1085 assert_eq!(
1086 &*centroids
1087 .probe(&query, 1, IvfRoutingMode::TwoLevel)
1088 .cluster_ids,
1089 &[0]
1090 );
1091 assert_eq!(
1092 centroids
1093 .assign_with_routing(&query, IvfRoutingMode::TwoLevel)
1094 .primary_cluster,
1095 best_leaf as u32
1096 );
1097 assert_eq!(
1098 centroids
1099 .assign_with_soar_and_routing(
1100 &query,
1101 &SoarConfig::full(),
1102 IvfRoutingMode::TwoLevel,
1103 )
1104 .primary_cluster,
1105 best_leaf as u32
1106 );
1107 }
1108
1109 #[test]
1110 fn selective_soar_calibrates_to_a_storage_budget() {
1111 let centroids = CoarseCentroids {
1112 num_clusters: 2,
1113 dim: 2,
1114 centroids: vec![0.0, 0.0, 10.0, 0.0],
1115 version: 1,
1116 soar_config: None,
1117 routing_index: None,
1118 };
1119 let vectors: Vec<Vec<f32>> = (0..100)
1120 .map(|index| vec![index as f32 / 100.0, 0.0])
1121 .collect();
1122 let flat = vectors.iter().flatten().copied().collect::<Vec<_>>();
1123 let threshold = centroids.calibrate_selective_spill_threshold(
1124 &flat,
1125 vectors.len(),
1126 IvfRoutingMode::Flat,
1127 0.30,
1128 );
1129 let spilled = vectors
1130 .iter()
1131 .filter(|vector| squared_l2(vector, centroids.get_centroid(0)).sqrt() >= threshold)
1132 .count();
1133 assert!((29..=31).contains(&spilled), "{spilled}");
1134 }
1135
1136 #[test]
1137 fn selective_soar_never_exceeds_budget_when_residuals_tie() {
1138 let centroids = CoarseCentroids {
1139 num_clusters: 2,
1140 dim: 2,
1141 centroids: vec![0.0, 0.0, 10.0, 0.0],
1142 version: 1,
1143 soar_config: None,
1144 routing_index: None,
1145 };
1146 let vectors = [1.0f32, 0.0].repeat(100);
1147 let threshold = centroids.calibrate_selective_spill_threshold(
1148 &vectors,
1149 100,
1150 IvfRoutingMode::Flat,
1151 0.30,
1152 );
1153 let config = SoarConfig::new().threshold(threshold);
1154 let spilled = vectors
1155 .chunks_exact(2)
1156 .filter(|vector| centroids.assign_with_soar(vector, &config).is_spilled())
1157 .count();
1158
1159 assert!(threshold > 1.0);
1160 assert!(spilled <= 30, "{spilled}");
1161 }
1162
1163 #[test]
1164 fn selective_soar_preserves_boundary_query_candidate_recall_with_bounded_postings() {
1165 const DIM: usize = 16;
1166 const CLUSTERS: usize = 16;
1167 const MEMBERS_PER_CLUSTER: usize = 128;
1168 const TOP_K: usize = 20;
1169 const TARGET_SPILL: f32 = 0.30;
1170
1171 fn normalize(values: &mut [f32]) {
1172 let norm = values.iter().map(|value| value * value).sum::<f32>().sqrt();
1173 for value in values {
1174 *value /= norm;
1175 }
1176 }
1177
1178 let mut rng = rand::rngs::StdRng::seed_from_u64(0x50a4_5eed);
1179 let source_centers: Vec<Vec<f32>> = (0..CLUSTERS)
1180 .map(|_| {
1181 let mut center: Vec<f32> = (0..DIM).map(|_| rng.random::<f32>() - 0.5).collect();
1182 normalize(&mut center);
1183 center
1184 })
1185 .collect();
1186 let corpus: Vec<Vec<f32>> = source_centers
1187 .iter()
1188 .flat_map(|center| {
1189 (0..MEMBERS_PER_CLUSTER)
1190 .map(|_| {
1191 let mut noise: Vec<f32> =
1192 (0..DIM).map(|_| rng.random::<f32>() - 0.5).collect();
1193 normalize(&mut noise);
1194 let mut vector: Vec<f32> = center
1195 .iter()
1196 .zip(noise)
1197 .map(|(&value, noise)| value + 0.75 * noise)
1198 .collect();
1199 normalize(&mut vector);
1200 vector
1201 })
1202 .collect::<Vec<_>>()
1203 })
1204 .collect();
1205
1206 let selective = CoarseCentroids::train(
1207 &CoarseConfig::new(DIM, CLUSTERS)
1208 .with_seed(0x1f4)
1209 .with_routing(IvfRoutingMode::Flat)
1210 .with_soar(SoarConfig::new().target_spill_fraction(TARGET_SPILL)),
1211 &corpus,
1212 "test",
1213 );
1214 let mut primary_only = selective.clone();
1217 primary_only.soar_config = None;
1218
1219 let primary_assignments: Vec<MultiAssignment> = corpus
1220 .iter()
1221 .map(|vector| primary_only.assign(vector))
1222 .collect();
1223 let selective_assignments: Vec<MultiAssignment> = corpus
1224 .iter()
1225 .map(|vector| selective.assign(vector))
1226 .collect();
1227 for (primary, spilled) in primary_assignments.iter().zip(&selective_assignments) {
1228 assert_eq!(
1229 primary.primary_cluster, spilled.primary_cluster,
1230 "SOAR policy must not change the primary posting"
1231 );
1232 }
1233
1234 let spilled = selective_assignments
1235 .iter()
1236 .filter(|assignment| assignment.is_spilled())
1237 .count();
1238 let posting_factor = selective_assignments
1239 .iter()
1240 .map(MultiAssignment::num_assignments)
1241 .sum::<usize>() as f32
1242 / corpus.len() as f32;
1243 let spill_budget = (corpus.len() as f32 * TARGET_SPILL).round() as usize;
1244 assert!(
1245 spilled > 0,
1246 "the smoke corpus must exercise selective spilling"
1247 );
1248 assert!(
1249 spilled <= spill_budget,
1250 "{spilled} spilled vectors exceeded the calibrated budget of {spill_budget}"
1251 );
1252 assert!(
1253 posting_factor <= 1.0 + TARGET_SPILL + f32::EPSILON,
1254 "posting amplification {posting_factor:.4} exceeded the 1.30 calibration target"
1255 );
1256
1257 let queries: Vec<Vec<f32>> = (0..selective.num_clusters)
1261 .map(|left| {
1262 let left_centroid = selective.get_centroid(left);
1263 let right = (0..selective.num_clusters)
1264 .filter(|&candidate| candidate != left)
1265 .min_by(|&a, &b| {
1266 squared_l2(left_centroid, selective.get_centroid(a))
1267 .total_cmp(&squared_l2(left_centroid, selective.get_centroid(b)))
1268 .then_with(|| a.cmp(&b))
1269 })
1270 .unwrap();
1271 let mut query: Vec<f32> = left_centroid
1272 .iter()
1273 .zip(selective.get_centroid(right))
1274 .map(|(&a, &b)| 0.51 * a + 0.49 * b)
1275 .collect();
1276 normalize(&mut query);
1277 query
1278 })
1279 .collect();
1280
1281 let mut gained_queries = 0usize;
1282 for nprobe in [1, 2] {
1283 let mut primary_hits = 0usize;
1284 let mut selective_hits = 0usize;
1285 for query in &queries {
1286 let primary_plan = primary_only.probe(query, nprobe, IvfRoutingMode::Flat);
1287 let selective_plan = selective.probe(query, nprobe, IvfRoutingMode::Flat);
1288 assert_eq!(
1289 primary_plan.cluster_ids, selective_plan.cluster_ids,
1290 "SOAR must not alter query routing"
1291 );
1292
1293 let mut truth: Vec<(usize, f32)> = corpus
1294 .iter()
1295 .enumerate()
1296 .map(|(document, vector)| (document, squared_l2(query, vector)))
1297 .collect();
1298 truth.select_nth_unstable_by(TOP_K, |left, right| {
1299 left.1
1300 .total_cmp(&right.1)
1301 .then_with(|| left.0.cmp(&right.0))
1302 });
1303 truth.truncate(TOP_K);
1304
1305 let query_primary_hits = truth
1306 .iter()
1307 .filter(|&&(document, _)| {
1308 primary_assignments[document]
1309 .all_clusters()
1310 .any(|cluster| primary_plan.cluster_ids.contains(&cluster))
1311 })
1312 .count();
1313 let query_selective_hits = truth
1314 .iter()
1315 .filter(|&&(document, _)| {
1316 selective_assignments[document]
1317 .all_clusters()
1318 .any(|cluster| selective_plan.cluster_ids.contains(&cluster))
1319 })
1320 .count();
1321 primary_hits += query_primary_hits;
1322 selective_hits += query_selective_hits;
1323 gained_queries += usize::from(query_selective_hits > query_primary_hits);
1324 }
1325
1326 let denominator = (queries.len() * TOP_K) as f32;
1327 let primary_recall = primary_hits as f32 / denominator;
1328 let selective_recall = selective_hits as f32 / denominator;
1329 assert!(
1330 selective_recall + 0.005 >= primary_recall,
1331 "selective SOAR candidate recall regressed at nprobe={nprobe}: \
1332 {selective_recall:.4} vs {primary_recall:.4}"
1333 );
1334 if nprobe == 1 {
1335 assert!(
1336 selective_recall >= primary_recall + 0.01,
1337 "selective SOAR must recover boundary candidates at nprobe=1: \
1338 {selective_recall:.4} vs {primary_recall:.4}"
1339 );
1340 }
1341 }
1342 assert!(
1343 gained_queries > 0,
1344 "boundary-query smoke did not exercise a selective SOAR recall gain"
1345 );
1346 }
1347
1348 #[test]
1349 fn soar_does_not_choose_an_arbitrarily_distant_orthogonal_centroid() {
1350 let centroids = CoarseCentroids {
1351 num_clusters: 3,
1352 dim: 2,
1353 centroids: vec![-1.0, 0.0, 0.0, -100.0, 1.1, 0.0],
1358 version: 1,
1359 soar_config: None,
1360 routing_index: None,
1361 };
1362
1363 let assignment = centroids.assign_with_soar(&[0.0, 0.0], &SoarConfig::full());
1364 assert_eq!(assignment.primary_cluster, 0);
1365 assert_eq!(assignment.secondary_clusters, vec![2]);
1366 }
1367
1368 #[test]
1369 fn selective_soar_rejects_a_far_secondary_residual() {
1370 let centroids = CoarseCentroids {
1371 num_clusters: 2,
1372 dim: 2,
1373 centroids: vec![0.0, 0.0, 10.0, 0.0],
1374 version: 1,
1375 soar_config: None,
1376 routing_index: None,
1377 };
1378 let config = SoarConfig::new().threshold(0.0);
1379
1380 let assignment = centroids.assign_with_soar(&[1.0, 0.0], &config);
1383 assert_eq!(assignment.primary_cluster, 0);
1384 assert!(assignment.secondary_clusters.is_empty());
1385 }
1386
1387 #[test]
1388 fn selective_soar_keeps_a_comparable_boundary_secondary() {
1389 let centroids = CoarseCentroids {
1390 num_clusters: 2,
1391 dim: 2,
1392 centroids: vec![0.0, 0.0, 2.0, 0.0],
1393 version: 1,
1394 soar_config: None,
1395 routing_index: None,
1396 };
1397 let config = SoarConfig::new().threshold(0.0);
1398
1399 let assignment = centroids.assign_with_soar(&[0.9, 0.1], &config);
1402 assert_eq!(assignment.primary_cluster, 0);
1403 assert_eq!(assignment.secondary_clusters, vec![1]);
1404 }
1405
1406 #[test]
1407 fn soar_secondary_ties_are_ordered_by_cluster_id_and_capped_to_one() {
1408 let centroids = CoarseCentroids {
1409 num_clusters: 3,
1410 dim: 2,
1411 centroids: vec![0.0, 0.0, 1.0, 0.0, -1.0, 0.0],
1412 version: 1,
1413 soar_config: None,
1414 routing_index: None,
1415 };
1416 let config = SoarConfig {
1417 num_secondary: 2,
1418 selective: false,
1419 spill_threshold: 0.0,
1420 };
1421
1422 let assignment = centroids.assign_with_soar(&[0.0, 0.0], &config);
1423 assert_eq!(assignment.primary_cluster, 0);
1424 assert_eq!(assignment.secondary_clusters, vec![1]);
1425 }
1426
1427 #[test]
1428 fn test_serialization() {
1429 let dim = 16;
1430 let n = 50;
1431 let num_clusters = 4;
1432
1433 let mut rng = rand::rngs::StdRng::seed_from_u64(789);
1434 let vectors: Vec<Vec<f32>> = (0..n)
1435 .map(|_| (0..dim).map(|_| rng.random::<f32>()).collect())
1436 .collect();
1437
1438 let config = CoarseConfig::new(dim, num_clusters);
1439 let centroids = CoarseCentroids::train(&config, &vectors, "test");
1440
1441 let bytes = bincode::serde::encode_to_vec(¢roids, bincode::config::standard()).unwrap();
1443 let (loaded, consumed): (CoarseCentroids, usize) =
1444 bincode::serde::decode_from_slice(&bytes, bincode::config::standard()).unwrap();
1445 assert_eq!(consumed, bytes.len());
1446
1447 assert_eq!(loaded.num_clusters, centroids.num_clusters);
1448 assert_eq!(loaded.dim, centroids.dim);
1449 assert_eq!(loaded.centroids.len(), centroids.centroids.len());
1450 }
1451
1452 #[test]
1453 fn persisted_hnsw_and_two_level_routers_are_valid() {
1454 let dim = 4;
1455 let mut rng = rand::rngs::StdRng::seed_from_u64(991);
1456 let vectors: Vec<Vec<f32>> = (0..256)
1457 .map(|_| (0..dim).map(|_| rng.random::<f32>()).collect())
1458 .collect();
1459
1460 for routing in [IvfRoutingMode::Hnsw, IvfRoutingMode::TwoLevel] {
1461 let trained = CoarseCentroids::train(
1462 &CoarseConfig::new(dim, 16).with_routing(routing),
1463 &vectors,
1464 "test",
1465 );
1466 trained.validate_routing(routing).unwrap();
1467 let plan = trained.probe(&vectors[0], 8, routing);
1468 assert_eq!(plan.cluster_ids.len(), 8);
1469 assert!(
1470 plan.cluster_ids
1471 .iter()
1472 .all(|&cluster| cluster < trained.num_clusters)
1473 );
1474
1475 let bytes =
1476 bincode::serde::encode_to_vec(&trained, bincode::config::standard()).unwrap();
1477 let (loaded, consumed): (CoarseCentroids, usize) =
1478 bincode::serde::decode_from_slice(&bytes, bincode::config::standard()).unwrap();
1479 assert_eq!(consumed, bytes.len());
1480 loaded.validate_routing(routing).unwrap();
1481 }
1482 }
1483}