Skip to main content

hermes_core/structures/vector/ivf/
coarse.rs

1//! Coarse centroids for IVF partitioning
2//!
3//! Provides k-means clustering for the first level of IVF indexing.
4//! Trained once, shared across all segments for O(1) merge compatibility.
5
6use 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
16// The SOAR paper evaluates lambda = 1. Keep it private until a different
17// value has recall/latency evidence and can be added without changing the
18// serialized public configuration.
19const SOAR_LAMBDA: f32 = 1.0;
20/// Selective spilling is intended for boundary vectors, whose primary and
21/// secondary residuals have comparable distortion. Encoding a residual to a
22/// much farther secondary centroid both wastes a posting and amplifies TQ
23/// estimation error enough to crowd better exact-rerank candidates out.
24///
25/// Compare squared distances, so `4` permits a secondary residual up to twice
26/// the primary residual norm. Full spilling remains unconditional.
27const MAX_SELECTIVE_SECONDARY_TO_PRIMARY_DISTANCE_RATIO_SQ: f32 = 4.0;
28/// Construction is offline, so expand more of a hierarchical router than a
29/// latency-sensitive one-leaf query. This reduces permanent misassignment
30/// without returning to an O(K) centroid scan for large codebooks.
31const BUILD_ASSIGNMENT_CANDIDATES: usize = 128;
32
33/// Configuration for coarse quantizer
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct CoarseConfig {
36    /// Number of clusters
37    pub num_clusters: usize,
38    /// Vector dimension
39    pub dim: usize,
40    /// Maximum k-means iterations
41    pub max_iters: usize,
42    /// Random seed for reproducibility
43    pub seed: u64,
44    /// SOAR configuration (optional)
45    pub soar: Option<SoarConfig>,
46    /// Flat, two-level, or HNSW routing. Auto chooses from the final leaf count.
47    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/// Coarse centroids for IVF - trained once, shared across all segments
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct CoarseCentroids {
86    /// Number of clusters
87    pub num_clusters: u32,
88    /// Vector dimension
89    pub dim: usize,
90    /// Centroids stored as flat array (num_clusters × dim)
91    pub centroids: Vec<f32>,
92    /// Version for compatibility checking during merge
93    pub version: u64,
94    /// SOAR configuration (if enabled)
95    pub soar_config: Option<SoarConfig>,
96    /// Persisted parent centroids and topology for sublinear leaf routing.
97    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
109struct FlatClusterMemberships {
110    offsets: Vec<usize>,
111    members: Vec<usize>,
112}
113
114impl FlatClusterMemberships {
115    fn cluster(&self, cluster: usize) -> &[usize] {
116        &self.members[self.offsets[cluster]..self.offsets[cluster + 1]]
117    }
118}
119
120impl CoarseCentroids {
121    /// Train coarse centroids using k-means algorithm
122    ///
123    /// Uses deterministic adaptive D² seeding and Lloyd refinement.
124    pub fn train(config: &CoarseConfig, vectors: &[Vec<f32>], index_label: &str) -> Self {
125        assert!(!vectors.is_empty(), "Cannot train on empty vector set");
126        assert!(config.num_clusters > 0, "Need at least 1 cluster");
127        assert!(vectors.iter().all(|vector| vector.len() == config.dim));
128
129        // Keep the public row-oriented API for callers and tests, but funnel
130        // production training through one contiguous matrix so the build path
131        // does not allocate one heap object per sampled vector.
132        let flat = vectors
133            .iter()
134            .flat_map(|vector| vector.iter().copied())
135            .collect::<Vec<_>>();
136        Self::train_contiguous(config, &flat, vectors.len(), index_label)
137    }
138
139    /// Train directly from a contiguous row-major matrix.
140    pub(crate) fn train_contiguous(
141        config: &CoarseConfig,
142        vectors: &[f32],
143        vector_count: usize,
144        index_label: &str,
145    ) -> Self {
146        assert!(vector_count > 0, "Cannot train on empty vector set");
147        assert!(config.num_clusters > 0, "Need at least 1 cluster");
148        assert_eq!(vectors.len(), vector_count.saturating_mul(config.dim));
149
150        let actual_clusters = config.num_clusters.min(vector_count);
151        let (centroids, routing_index) =
152            match effective_routing_mode(config.routing, actual_clusters) {
153                IvfRoutingMode::TwoLevel => {
154                    let (leaves, router) =
155                        Self::train_hierarchical(config, vectors, vector_count, actual_clusters);
156                    (leaves, Some(router))
157                }
158                IvfRoutingMode::Hnsw => {
159                    let leaves = if actual_clusters >= HIERARCHICAL_TRAINING_THRESHOLD {
160                        Self::train_hierarchical(config, vectors, vector_count, actual_clusters).0
161                    } else {
162                        Self::train_flat(config, vectors, vector_count, actual_clusters)
163                    };
164                    let graph = HnswRoutingGraph::build(
165                        actual_clusters,
166                        |left, right| {
167                            let left = left as usize * config.dim;
168                            let right = right as usize * config.dim;
169                            squared_l2(
170                                &leaves[left..left + config.dim],
171                                &leaves[right..right + config.dim],
172                            )
173                        },
174                        config.seed,
175                        index_label,
176                    );
177                    (leaves, Some(FloatCentroidRouter::Hnsw(graph)))
178                }
179                IvfRoutingMode::Flat | IvfRoutingMode::Auto => (
180                    Self::train_flat(config, vectors, vector_count, actual_clusters),
181                    None,
182                ),
183            };
184
185        let version = std::time::SystemTime::now()
186            .duration_since(std::time::UNIX_EPOCH)
187            .unwrap_or_default()
188            .as_nanos() as u64;
189
190        let mut soar_config = config.soar.clone();
191        if let Some(soar) = &mut soar_config
192            && soar.num_secondary > 1
193        {
194            log::warn!(
195                "SOAR currently implements the published primary + one-secondary objective; \
196                 clamping {} requested secondary assignments to one",
197                soar.num_secondary,
198            );
199            soar.num_secondary = 1;
200        }
201        let calibration_target = soar_config
202            .as_ref()
203            .and_then(SoarConfig::calibration_target);
204        let mut trained = Self {
205            num_clusters: actual_clusters as u32,
206            dim: config.dim,
207            centroids,
208            version,
209            // Install the SOAR policy after calibration so primary assignment
210            // below cannot recursively request secondary leaves.
211            soar_config: None,
212            routing_index,
213        };
214        if let Some(target) = calibration_target {
215            let threshold = trained.calibrate_selective_spill_threshold(
216                vectors,
217                vector_count,
218                config.routing,
219                target,
220            );
221            if let Some(soar) = &mut soar_config {
222                soar.spill_threshold = threshold;
223            }
224            log::info!(
225                "Calibrated SOAR selective spilling to at most {:.1}% of the training sample \
226                 (residual threshold {:.6})",
227                target * 100.0,
228                threshold,
229            );
230        }
231        trained.soar_config = soar_config;
232        trained
233    }
234
235    fn calibrate_selective_spill_threshold(
236        &self,
237        vectors: &[f32],
238        vector_count: usize,
239        routing: IvfRoutingMode,
240        target_fraction: f32,
241    ) -> f32 {
242        // Bound calibration independently of the caller's raw training
243        // sample. Flat routing pays O(KD) per selected vector; hierarchical
244        // routers can afford a larger validation slice.
245        let routing = effective_routing_mode(routing, self.num_clusters as usize);
246        let sample_limit = match routing {
247            IvfRoutingMode::Flat | IvfRoutingMode::Auto => {
248                let work_per_vector = (self.num_clusters as usize).saturating_mul(self.dim).max(1);
249                (64_000_000usize / work_per_vector).clamp(32, 4_096)
250            }
251            IvfRoutingMode::TwoLevel | IvfRoutingMode::Hnsw => 8_192,
252        }
253        .min(vector_count);
254        let mut residual_norms = Vec::with_capacity(sample_limit);
255        for sample in 0..sample_limit {
256            let vector_index = sample.saturating_mul(vector_count) / sample_limit;
257            let offset = vector_index * self.dim;
258            let vector = &vectors[offset..offset + self.dim];
259            let primary = self.assign_with_routing(vector, routing).primary_cluster;
260            residual_norms.push(squared_l2(vector, self.get_centroid(primary)).sqrt());
261        }
262        residual_norms.sort_unstable_by(f32::total_cmp);
263        if residual_norms.is_empty() {
264            return 0.0;
265        }
266        let spill_count = ((residual_norms.len() as f32 * target_fraction.clamp(0.0, 1.0)).round()
267            as usize)
268            .min(residual_norms.len());
269        if spill_count == 0 {
270            let largest = residual_norms.last().copied().unwrap_or(0.0);
271            return threshold_strictly_above(largest);
272        }
273        let boundary = residual_norms[residual_norms.len() - spill_count];
274        let at_or_above =
275            residual_norms.len() - residual_norms.partition_point(|&norm| norm < boundary);
276        if at_or_above > spill_count {
277            // Equality spills, so a quantile that lands in a tie could exceed
278            // the configured storage budget—up to 100% for identical
279            // residuals. Move strictly above the tied value; underfilling is
280            // preferable to an unbounded posting expansion.
281            threshold_strictly_above(boundary)
282        } else {
283            boundary
284        }
285    }
286
287    fn train_flat(
288        config: &CoarseConfig,
289        vectors: &[f32],
290        vector_count: usize,
291        clusters: usize,
292    ) -> Vec<f32> {
293        Self::run_kmeans(config, vectors, vector_count, clusters).centroids
294    }
295
296    fn train_flat_with_memberships(
297        config: &CoarseConfig,
298        vectors: &[f32],
299        vector_count: usize,
300        clusters: usize,
301    ) -> (Vec<f32>, FlatClusterMemberships) {
302        let trained = Self::run_kmeans(config, vectors, vector_count, clusters);
303        (
304            trained.centroids,
305            FlatClusterMemberships {
306                offsets: trained.member_offsets,
307                members: trained.members,
308            },
309        )
310    }
311
312    fn run_kmeans(
313        config: &CoarseConfig,
314        vectors: &[f32],
315        vector_count: usize,
316        clusters: usize,
317    ) -> crate::structures::vector::kmeans::EuclideanKMeans {
318        crate::structures::vector::kmeans::train_euclidean_kmeans(
319            vectors,
320            vector_count,
321            config.dim,
322            clusters,
323            config.max_iters,
324            config.seed,
325        )
326    }
327
328    fn train_hierarchical(
329        config: &CoarseConfig,
330        vectors: &[f32],
331        vector_count: usize,
332        leaf_count: usize,
333    ) -> (Vec<f32>, FloatCentroidRouter) {
334        let parent_count = routing_parent_count(leaf_count).min(vector_count);
335        let mut parent_config = config.clone();
336        parent_config.routing = IvfRoutingMode::Flat;
337        parent_config.num_clusters = parent_count;
338        let (parent_centroids, groups) =
339            Self::train_flat_with_memberships(&parent_config, vectors, vector_count, parent_count);
340        let group_sizes: Vec<usize> = (0..parent_count)
341            .map(|parent| groups.cluster(parent).len())
342            .collect();
343        let child_counts = allocate_child_clusters(&group_sizes, leaf_count);
344        let mut leaves = Vec::with_capacity(leaf_count.saturating_mul(config.dim));
345        let mut children = vec![Vec::new(); parent_count];
346        let mut group_vectors = Vec::new();
347
348        for (parent, &child_count) in child_counts.iter().enumerate() {
349            if child_count == 0 {
350                continue;
351            }
352            let indices = groups.cluster(parent);
353            group_vectors.clear();
354            group_vectors.reserve(indices.len().saturating_mul(config.dim));
355            for &index in indices {
356                let offset = index * config.dim;
357                group_vectors.extend_from_slice(&vectors[offset..offset + config.dim]);
358            }
359            let mut child_config = config.clone();
360            child_config.routing = IvfRoutingMode::Flat;
361            child_config.num_clusters = child_count;
362            child_config.seed = config.seed ^ (parent as u64).wrapping_mul(0x9e37_79b9_7f4a_7c15);
363            let first_leaf = leaves.len() / config.dim;
364            leaves.extend_from_slice(&Self::train_flat(
365                &child_config,
366                &group_vectors,
367                indices.len(),
368                child_count,
369            ));
370            children[parent].extend((first_leaf..first_leaf + child_count).map(|leaf| leaf as u32));
371        }
372        debug_assert_eq!(leaves.len(), leaf_count * config.dim);
373
374        (
375            leaves,
376            FloatCentroidRouter::TwoLevel {
377                parent_centroids,
378                topology: IvfRoutingTopology::from_children(&children),
379            },
380        )
381    }
382
383    /// Find nearest centroid index for a vector (static helper)
384    fn find_nearest_idx_static(vector: &[f32], centroids: &[f32], dim: usize) -> usize {
385        let num_clusters = centroids.len() / dim;
386        let mut best_idx = 0;
387        let mut best_dist = f32::MAX;
388
389        for c in 0..num_clusters {
390            let offset = c * dim;
391            let dist: f32 = vector
392                .iter()
393                .zip(&centroids[offset..offset + dim])
394                .map(|(&a, &b)| (a - b) * (a - b))
395                .sum();
396
397            if dist < best_dist {
398                best_dist = dist;
399                best_idx = c;
400            }
401        }
402
403        best_idx
404    }
405
406    /// Find nearest cluster for a query vector
407    pub fn find_nearest(&self, vector: &[f32]) -> u32 {
408        Self::find_nearest_idx_static(vector, &self.centroids, self.dim) as u32
409    }
410
411    /// Find k nearest clusters for a query vector
412    pub fn find_k_nearest(&self, vector: &[f32], k: usize) -> Vec<u32> {
413        let mut distances: Vec<(u32, f32)> = (0..self.num_clusters)
414            .map(|c| {
415                let offset = c as usize * self.dim;
416                let dist: f32 = vector
417                    .iter()
418                    .zip(&self.centroids[offset..offset + self.dim])
419                    .map(|(&a, &b)| (a - b) * (a - b))
420                    .sum();
421                (c, dist)
422            })
423            .collect();
424
425        // Partial sort: O(n + k log k) instead of O(n log n)
426        if distances.len() > k {
427            distances.select_nth_unstable_by(k, |a, b| a.1.total_cmp(&b.1));
428            distances.truncate(k);
429        }
430        distances.sort_unstable_by(|a, b| a.1.total_cmp(&b.1));
431        distances.into_iter().map(|(c, _)| c).collect()
432    }
433
434    /// Build a versioned probe plan using flat or two-level routing.
435    ///
436    /// The returned leaf IDs are independent of segment contents and can be
437    /// reused across every segment built from this global codebook.
438    pub fn probe(&self, vector: &[f32], k: usize, mode: IvfRoutingMode) -> IvfProbePlan {
439        let take = k.clamp(1, self.num_clusters as usize);
440        let clusters = match effective_routing_mode(mode, self.num_clusters as usize) {
441            IvfRoutingMode::Flat | IvfRoutingMode::Auto => self.find_k_nearest(vector, take),
442            IvfRoutingMode::TwoLevel => self.find_k_nearest_two_level(vector, take),
443            IvfRoutingMode::Hnsw => self.find_k_nearest_hnsw(vector, take),
444        };
445        IvfProbePlan::new(
446            self.version,
447            float_probe_fingerprint(vector, take, mode),
448            clusters,
449        )
450    }
451
452    pub fn validate_routing(&self, mode: IvfRoutingMode) -> Result<(), String> {
453        match effective_routing_mode(mode, self.num_clusters as usize) {
454            IvfRoutingMode::Flat | IvfRoutingMode::Auto => Ok(()),
455            IvfRoutingMode::TwoLevel => {
456                let Some(FloatCentroidRouter::TwoLevel {
457                    parent_centroids,
458                    topology,
459                }) = self.routing_index.as_ref()
460                else {
461                    return Err(
462                        "two-level IVF routing was requested but the global codebook has no matching router"
463                            .to_string(),
464                    );
465                };
466                let parent_count = topology.parent_count();
467                if parent_count == 0
468                    || parent_centroids.len() != parent_count.saturating_mul(self.dim)
469                    || !topology.validate(self.num_clusters as usize)
470                    || parent_centroids.iter().any(|value| !value.is_finite())
471                {
472                    return Err("invalid float two-level IVF routing index".to_string());
473                }
474                Ok(())
475            }
476            IvfRoutingMode::Hnsw => {
477                let Some(FloatCentroidRouter::Hnsw(graph)) = self.routing_index.as_ref() else {
478                    return Err(
479                        "HNSW IVF routing was requested but the global codebook has no HNSW graph"
480                            .to_string(),
481                    );
482                };
483                if !graph.validate(self.num_clusters as usize) {
484                    return Err("invalid float HNSW routing graph".to_string());
485                }
486                Ok(())
487            }
488        }
489    }
490
491    fn find_k_nearest_two_level(&self, vector: &[f32], k: usize) -> Vec<u32> {
492        self.find_k_nearest_two_level_impl::<false>(vector, k)
493    }
494
495    fn find_k_nearest_two_level_for_build(&self, vector: &[f32], k: usize) -> Vec<u32> {
496        self.find_k_nearest_two_level_impl::<true>(vector, k)
497    }
498
499    fn find_k_nearest_two_level_impl<const FOR_BUILD: bool>(
500        &self,
501        vector: &[f32],
502        k: usize,
503    ) -> Vec<u32> {
504        let Some(FloatCentroidRouter::TwoLevel {
505            parent_centroids,
506            topology,
507        }) = self.routing_index.as_ref()
508        else {
509            return self.find_k_nearest(vector, k);
510        };
511        if topology.parent_count() <= 1 {
512            return self.find_k_nearest(vector, k);
513        }
514
515        let mut parent_scores = vec![0.0; topology.parent_count()];
516        for (parent_id, score) in parent_scores.iter_mut().enumerate() {
517            let offset = parent_id * self.dim;
518            *score = squared_l2(vector, &parent_centroids[offset..offset + self.dim]);
519        }
520        let parents = if FOR_BUILD {
521            select_parent_beam_for_build::<false>(&parent_scores, topology, k)
522        } else {
523            select_parent_beam::<false>(&parent_scores, topology, k)
524        };
525        let candidate_capacity = parents
526            .iter()
527            .map(|&parent| topology.children(parent as usize).len())
528            .sum();
529        let mut candidates = Vec::with_capacity(candidate_capacity);
530        for parent in parents {
531            for &leaf in topology.children(parent as usize) {
532                candidates.push((leaf, squared_l2(vector, self.get_centroid(leaf))));
533            }
534        }
535        select_best_candidates::<false>(&mut candidates, k)
536    }
537
538    fn find_k_nearest_hnsw(&self, vector: &[f32], k: usize) -> Vec<u32> {
539        let Some(FloatCentroidRouter::Hnsw(graph)) = self.routing_index.as_ref() else {
540            return self.find_k_nearest(vector, k);
541        };
542        graph.search(|leaf| squared_l2(vector, self.get_centroid(leaf)), k)
543    }
544
545    /// Find k nearest clusters with their distances
546    pub fn find_k_nearest_with_distances(&self, vector: &[f32], k: usize) -> Vec<(u32, f32)> {
547        let mut distances: Vec<(u32, f32)> = (0..self.num_clusters)
548            .map(|c| {
549                let offset = c as usize * self.dim;
550                let dist: f32 = vector
551                    .iter()
552                    .zip(&self.centroids[offset..offset + self.dim])
553                    .map(|(&a, &b)| (a - b) * (a - b))
554                    .sum();
555                (c, dist)
556            })
557            .collect();
558
559        // Partial sort: O(n + k log k) instead of O(n log n)
560        if distances.len() > k {
561            distances.select_nth_unstable_by(k, |a, b| a.1.total_cmp(&b.1));
562            distances.truncate(k);
563        }
564        distances.sort_unstable_by(|a, b| a.1.total_cmp(&b.1));
565        distances
566    }
567
568    /// Assign vector with SOAR (if configured) or standard assignment
569    pub fn assign(&self, vector: &[f32]) -> MultiAssignment {
570        self.assign_with_routing(vector, IvfRoutingMode::Flat)
571    }
572
573    /// Assign during segment construction through the same persisted router
574    /// used at query time. Large codebooks therefore avoid an O(K) scan for
575    /// every indexed vector.
576    pub fn assign_with_routing(&self, vector: &[f32], routing: IvfRoutingMode) -> MultiAssignment {
577        if let Some(ref soar_config) = self.soar_config {
578            self.assign_with_soar_and_routing(vector, soar_config, routing)
579        } else {
580            let primary_cluster = match effective_routing_mode(routing, self.num_clusters as usize)
581            {
582                IvfRoutingMode::Hnsw => self.find_nearest_hnsw_for_build(vector),
583                IvfRoutingMode::TwoLevel => self
584                    .find_k_nearest_two_level_for_build(
585                        vector,
586                        BUILD_ASSIGNMENT_CANDIDATES.min(self.num_clusters as usize),
587                    )
588                    .first()
589                    .copied()
590                    .unwrap_or(0),
591                IvfRoutingMode::Flat | IvfRoutingMode::Auto => self.find_nearest(vector),
592            };
593            MultiAssignment {
594                primary_cluster,
595                secondary_clusters: Vec::new(),
596            }
597        }
598    }
599
600    /// SOAR-style assignment: balance secondary distortion and residual orthogonality
601    pub fn assign_with_soar(&self, vector: &[f32], config: &SoarConfig) -> MultiAssignment {
602        self.assign_with_soar_and_routing(vector, config, IvfRoutingMode::Flat)
603    }
604
605    fn assign_with_soar_and_routing(
606        &self,
607        vector: &[f32],
608        config: &SoarConfig,
609        routing: IvfRoutingMode,
610    ) -> MultiAssignment {
611        // The implemented SOAR loss is the published primary + one-secondary
612        // objective. Treat larger manually constructed values the same as the
613        // trained/config-parsed path instead of pretending repeated independent
614        // minimization implements the generalized multi-spill objective.
615        let num_secondary = config.num_secondary.min(1);
616        // Secondary assignment needs a meaningfully larger candidate pool
617        // than the number of requested spills; otherwise a skewed two-level
618        // topology can leave the SOAR loss no alternatives to rank.
619        let candidate_budget =
620            soar_build_candidate_budget(num_secondary, self.num_clusters as usize);
621        let leaf_ids: Vec<u32> = match effective_routing_mode(routing, self.num_clusters as usize) {
622            IvfRoutingMode::TwoLevel => {
623                self.two_level_candidate_leaves_for_build(vector, candidate_budget)
624            }
625            IvfRoutingMode::Hnsw => self.find_k_nearest_hnsw_for_build(vector, candidate_budget),
626            IvfRoutingMode::Flat | IvfRoutingMode::Auto => (0..self.num_clusters).collect(),
627        };
628        // Compute every candidate distance once. Reuse it both for primary
629        // selection and as the distortion term in the secondary SOAR loss.
630        let leaf_distances: Vec<(u32, f32)> = leaf_ids
631            .into_iter()
632            .map(|cluster| (cluster, squared_l2(vector, self.get_centroid(cluster))))
633            .collect();
634        let primary = leaf_distances
635            .iter()
636            .min_by(|left, right| scored_cluster_order(left, right))
637            .map(|&(cluster, _)| cluster)
638            .unwrap_or(0);
639        let primary_centroid = self.get_centroid(primary);
640
641        // 2. Compute primary residual r = x - c
642        let residual: Vec<f32> = vector
643            .iter()
644            .zip(primary_centroid)
645            .map(|(v, c)| v - c)
646            .collect();
647
648        let residual_norm_sq: f32 = residual.iter().map(|x| x * x).sum();
649
650        // 3. Check if we should spill (selective spilling)
651        if config.selective && residual_norm_sq < config.spill_threshold * config.spill_threshold {
652            return MultiAssignment {
653                primary_cluster: primary,
654                secondary_clusters: Vec::new(),
655            };
656        }
657
658        // 4. Minimize the published lambda=1 SOAR objective:
659        //
660        //      ||r'||² + lambda * ||proj_r(r')||²
661        //
662        // This retains ordinary secondary quantization quality while penalizing
663        // correlation with the primary residual. Optimizing only the projection
664        // term can otherwise select an arbitrarily distant orthogonal centroid.
665        let mut candidates: Vec<(u32, f32)> = leaf_distances
666            .into_iter()
667            .filter(|&(cluster, secondary_residual_norm_sq)| {
668                cluster != primary
669                    && (!config.selective
670                        || secondary_residual_norm_sq
671                            <= MAX_SELECTIVE_SECONDARY_TO_PRIMARY_DISTANCE_RATIO_SQ
672                                * residual_norm_sq)
673            })
674            .map(|(cluster, secondary_residual_norm_sq)| {
675                (
676                    cluster,
677                    soar_secondary_loss(
678                        vector,
679                        self.get_centroid(cluster),
680                        &residual,
681                        residual_norm_sq,
682                        secondary_residual_norm_sq,
683                    ),
684                )
685            })
686            .collect();
687
688        // Select by loss, then sort the retained prefix so ties and assignment
689        // order are deterministic across platforms and repeated builds.
690        let take = num_secondary.min(candidates.len());
691        if candidates.len() > take {
692            candidates.select_nth_unstable_by(take, scored_cluster_order);
693            candidates.truncate(take);
694        }
695        candidates.sort_unstable_by(scored_cluster_order);
696
697        MultiAssignment {
698            primary_cluster: primary,
699            secondary_clusters: candidates
700                .iter()
701                .take(num_secondary)
702                .map(|(c, _)| *c)
703                .collect(),
704        }
705    }
706
707    fn two_level_candidate_leaves_for_build(&self, vector: &[f32], k: usize) -> Vec<u32> {
708        let Some(FloatCentroidRouter::TwoLevel {
709            parent_centroids,
710            topology,
711        }) = self.routing_index.as_ref()
712        else {
713            return (0..self.num_clusters).collect();
714        };
715        let mut parent_scores = vec![0.0; topology.parent_count()];
716        for (parent_id, score) in parent_scores.iter_mut().enumerate() {
717            let offset = parent_id * self.dim;
718            *score = squared_l2(vector, &parent_centroids[offset..offset + self.dim]);
719        }
720        let parents = select_parent_beam_for_build::<false>(&parent_scores, topology, k);
721        let capacity = parents
722            .iter()
723            .map(|&parent| topology.children(parent as usize).len())
724            .sum();
725        let mut leaves = Vec::with_capacity(capacity);
726        for parent in parents {
727            leaves.extend_from_slice(topology.children(parent as usize));
728        }
729        leaves
730    }
731
732    fn find_k_nearest_hnsw_for_build(&self, vector: &[f32], k: usize) -> Vec<u32> {
733        let Some(FloatCentroidRouter::Hnsw(graph)) = self.routing_index.as_ref() else {
734            return self.find_k_nearest(vector, k);
735        };
736        graph.search_for_build(|leaf| squared_l2(vector, self.get_centroid(leaf)), k)
737    }
738
739    /// Single-leaf assignment. Construction routes every vector through here,
740    /// so it avoids the one-element result `Vec` the ranked form allocates.
741    fn find_nearest_hnsw_for_build(&self, vector: &[f32]) -> u32 {
742        let Some(FloatCentroidRouter::Hnsw(graph)) = self.routing_index.as_ref() else {
743            return self.find_nearest(vector);
744        };
745        graph
746            .search_best_for_build(|leaf| squared_l2(vector, self.get_centroid(leaf)))
747            .unwrap_or(0)
748    }
749
750    /// Get centroid for a cluster
751    pub fn get_centroid(&self, cluster_id: u32) -> &[f32] {
752        let offset = cluster_id as usize * self.dim;
753        &self.centroids[offset..offset + self.dim]
754    }
755
756    /// Compute residual vector (vector - centroid)
757    pub fn compute_residual(&self, vector: &[f32], cluster_id: u32) -> Vec<f32> {
758        let centroid = self.get_centroid(cluster_id);
759        vector.iter().zip(centroid).map(|(&v, &c)| v - c).collect()
760    }
761
762    /// Memory usage in bytes
763    pub fn size_bytes(&self) -> usize {
764        let routing_bytes = self
765            .routing_index
766            .as_ref()
767            .map_or(0, |router| match router {
768                FloatCentroidRouter::TwoLevel {
769                    parent_centroids,
770                    topology,
771                } => {
772                    parent_centroids.len() * size_of::<f32>()
773                        + topology.parent_count() * size_of::<u32>()
774                        + self.num_clusters as usize * size_of::<u32>()
775                }
776                FloatCentroidRouter::Hnsw(graph) => graph.size_bytes(),
777            });
778        self.centroids.len() * size_of::<f32>() + routing_bytes + 64
779    }
780
781    /// Visit compact routing topology and parent arrays before the potentially
782    /// much larger leaf centroid matrix.
783    #[cfg(feature = "native")]
784    pub(crate) fn visit_routing_regions(&self, visit: &mut dyn FnMut(&'static str, &[u8])) {
785        if let Some(router) = &self.routing_index {
786            match router {
787                FloatCentroidRouter::TwoLevel {
788                    parent_centroids,
789                    topology,
790                } => {
791                    topology.visit_resident_regions(visit);
792                    visit(
793                        "float parent centroids",
794                        super::routing::bytes_of_slice(parent_centroids),
795                    );
796                }
797                FloatCentroidRouter::Hnsw(graph) => graph.visit_resident_regions(visit),
798            }
799        }
800    }
801
802    #[cfg(feature = "native")]
803    pub(crate) fn visit_leaf_centroid_region(&self, visit: &mut dyn FnMut(&'static str, &[u8])) {
804        visit(
805            "float leaf centroids",
806            super::routing::bytes_of_slice(&self.centroids),
807        );
808    }
809
810    /// Encode the current index-level centroid artifact format.
811    pub fn to_bytes(&self) -> std::io::Result<Vec<u8>> {
812        bincode::serde::encode_to_vec(self, bincode::config::standard())
813            .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))
814    }
815}
816
817#[inline]
818fn squared_l2(left: &[f32], right: &[f32]) -> f32 {
819    crate::structures::simd::squared_l2_f32(left, right)
820}
821
822#[inline]
823fn threshold_strictly_above(value: f32) -> f32 {
824    if !value.is_finite() {
825        return f32::INFINITY;
826    }
827    // A single ULP is not sufficient near zero because the assignment path
828    // compares squared norms and a subnormal threshold can square back to
829    // zero. A small relative-or-absolute margin remains negligible for
830    // normalized vectors while guaranteeing a strictly larger squared bound.
831    let next = value + value.abs().max(1.0) * (4.0 * f32::EPSILON);
832    if next > value { next } else { f32::INFINITY }
833}
834
835#[inline]
836fn soar_build_candidate_budget(num_secondary: usize, num_clusters: usize) -> usize {
837    num_secondary
838        .saturating_add(1)
839        .saturating_mul(64)
840        .max(BUILD_ASSIGNMENT_CANDIDATES)
841        .min(num_clusters)
842}
843
844#[inline]
845fn soar_secondary_loss(
846    vector: &[f32],
847    secondary_centroid: &[f32],
848    primary_residual: &[f32],
849    primary_residual_norm_sq: f32,
850    secondary_residual_norm_sq: f32,
851) -> f32 {
852    let residual_dot = vector
853        .iter()
854        .zip(secondary_centroid)
855        .zip(primary_residual)
856        .fold(0.0f32, |acc, ((&value, &centroid), &primary)| {
857            acc.algebraic_add(primary.algebraic_mul(value - centroid))
858        });
859
860    // A zero primary residual already has no correlated score error. Define
861    // its projection penalty as zero rather than producing 0/0.
862    let projection_norm_sq = if primary_residual_norm_sq > 0.0 {
863        residual_dot * residual_dot / primary_residual_norm_sq
864    } else {
865        0.0
866    };
867    secondary_residual_norm_sq + SOAR_LAMBDA * projection_norm_sq
868}
869
870#[inline]
871fn scored_cluster_order(left: &(u32, f32), right: &(u32, f32)) -> std::cmp::Ordering {
872    left.1
873        .total_cmp(&right.1)
874        .then_with(|| left.0.cmp(&right.0))
875}
876
877#[cfg(test)]
878mod tests {
879    use super::*;
880    use rand::prelude::*;
881
882    #[test]
883    fn test_coarse_centroids_basic() {
884        let dim = 64;
885        let n = 1000;
886        let num_clusters = 16;
887
888        let mut rng = rand::rngs::StdRng::seed_from_u64(42);
889        let vectors: Vec<Vec<f32>> = (0..n)
890            .map(|_| (0..dim).map(|_| rng.random::<f32>() - 0.5).collect())
891            .collect();
892
893        let config = CoarseConfig::new(dim, num_clusters);
894        let centroids = CoarseCentroids::train(&config, &vectors, "test");
895
896        assert_eq!(centroids.num_clusters, num_clusters as u32);
897        assert_eq!(centroids.dim, dim);
898    }
899
900    #[test]
901    fn contiguous_training_matches_row_wrapper() {
902        let dim = 8;
903        let vectors: Vec<Vec<f32>> = (0..128)
904            .map(|row| {
905                (0..dim)
906                    .map(|column| ((row * 31 + column * 17) % 101) as f32 / 101.0)
907                    .collect()
908            })
909            .collect();
910        let flat = vectors.iter().flatten().copied().collect::<Vec<_>>();
911        let config = CoarseConfig::new(dim, 12)
912            .with_seed(91)
913            .with_routing(IvfRoutingMode::TwoLevel);
914
915        let rows = CoarseCentroids::train(&config, &vectors, "test");
916        let contiguous = CoarseCentroids::train_contiguous(&config, &flat, vectors.len(), "test");
917
918        assert_eq!(rows.centroids, contiguous.centroids);
919        assert_eq!(
920            bincode::serde::encode_to_vec(&rows.routing_index, bincode::config::standard())
921                .unwrap(),
922            bincode::serde::encode_to_vec(&contiguous.routing_index, bincode::config::standard())
923                .unwrap(),
924        );
925    }
926
927    #[test]
928    fn test_find_nearest() {
929        let dim = 32;
930        let n = 500;
931        let num_clusters = 8;
932
933        let mut rng = rand::rngs::StdRng::seed_from_u64(123);
934        let vectors: Vec<Vec<f32>> = (0..n)
935            .map(|_| (0..dim).map(|_| rng.random::<f32>()).collect())
936            .collect();
937
938        let config = CoarseConfig::new(dim, num_clusters);
939        let centroids = CoarseCentroids::train(&config, &vectors, "test");
940
941        // Test that find_nearest returns valid cluster IDs
942        for v in &vectors {
943            let cluster = centroids.find_nearest(v);
944            assert!(cluster < centroids.num_clusters);
945        }
946    }
947
948    #[test]
949    fn scaled_l2_probes_keep_distinct_cache_identities() {
950        let centroids = CoarseCentroids {
951            num_clusters: 2,
952            dim: 2,
953            centroids: vec![1.0, 0.0, 10.0, 0.0],
954            version: 7,
955            soar_config: None,
956            routing_index: None,
957        };
958
959        let near = centroids.probe(&[1.0, 0.0], 1, IvfRoutingMode::Flat);
960        let scaled = centroids.probe(&[100.0, 0.0], 1, IvfRoutingMode::Flat);
961
962        assert_eq!(&*near.cluster_ids, &[0]);
963        assert_eq!(&*scaled.cluster_ids, &[1]);
964        assert_ne!(near.request_fingerprint, scaled.request_fingerprint);
965    }
966
967    #[test]
968    fn test_soar_assignment() {
969        let dim = 32;
970        let n = 100;
971        let num_clusters = 8;
972
973        let mut rng = rand::rngs::StdRng::seed_from_u64(456);
974        let vectors: Vec<Vec<f32>> = (0..n)
975            .map(|_| (0..dim).map(|_| rng.random::<f32>()).collect())
976            .collect();
977
978        let soar_config = SoarConfig {
979            num_secondary: 2,
980            selective: false,
981            spill_threshold: 0.0,
982        };
983        let config = CoarseConfig::new(dim, num_clusters).with_soar(soar_config);
984        let centroids = CoarseCentroids::train(&config, &vectors, "test");
985
986        // Test SOAR assignment
987        let assignment = centroids.assign(&vectors[0]);
988        assert!(assignment.primary_cluster < centroids.num_clusters);
989        assert_eq!(centroids.soar_config.as_ref().unwrap().num_secondary, 1);
990        assert_eq!(assignment.secondary_clusters.len(), 1);
991
992        // Secondary clusters should be different from primary
993        for &sec in &assignment.secondary_clusters {
994            assert_ne!(sec, assignment.primary_cluster);
995        }
996    }
997
998    #[test]
999    fn soar_loss_includes_distortion_and_normalized_projection() {
1000        let vector = [0.0, 0.0];
1001        let primary_residual = [2.0, 0.0];
1002        let secondary_centroid = [-3.0, -4.0];
1003
1004        // r' = [3, 4], so ||r'||² = 25 and
1005        // ||proj_r(r')||² = <r,r'>² / ||r||² = 36 / 4 = 9.
1006        let loss = soar_secondary_loss(&vector, &secondary_centroid, &primary_residual, 4.0, 25.0);
1007        assert!((loss - 34.0).abs() <= f32::EPSILON);
1008    }
1009
1010    #[test]
1011    fn soar_routing_keeps_an_oversampled_secondary_candidate_pool() {
1012        assert_eq!(soar_build_candidate_budget(1, 1_000), 128);
1013        assert_eq!(soar_build_candidate_budget(2, 1_000), 192);
1014        assert_eq!(soar_build_candidate_budget(8, 64), 64);
1015    }
1016
1017    #[test]
1018    fn two_level_build_assignment_checks_four_parents_for_primary_and_soar() {
1019        let leaves_per_parent = 512;
1020        let children: Vec<Vec<u32>> = (0..4)
1021            .map(|parent| {
1022                let first = parent * leaves_per_parent;
1023                (first..first + leaves_per_parent)
1024                    .map(|leaf| leaf as u32)
1025                    .collect()
1026            })
1027            .collect();
1028        let mut leaf_centroids = vec![1.0; 4 * leaves_per_parent];
1029        let best_leaf = 3 * leaves_per_parent;
1030        leaf_centroids[best_leaf] = 0.0;
1031        let centroids = CoarseCentroids {
1032            num_clusters: leaf_centroids.len() as u32,
1033            dim: 1,
1034            centroids: leaf_centroids,
1035            version: 1,
1036            soar_config: None,
1037            routing_index: Some(FloatCentroidRouter::TwoLevel {
1038                parent_centroids: vec![0.0, 10.0, 20.0, 30.0],
1039                topology: IvfRoutingTopology::from_children(&children),
1040            }),
1041        };
1042
1043        let query = [0.0];
1044        assert_eq!(
1045            &*centroids
1046                .probe(&query, 1, IvfRoutingMode::TwoLevel)
1047                .cluster_ids,
1048            &[0]
1049        );
1050        assert_eq!(
1051            centroids
1052                .assign_with_routing(&query, IvfRoutingMode::TwoLevel)
1053                .primary_cluster,
1054            best_leaf as u32
1055        );
1056        assert_eq!(
1057            centroids
1058                .assign_with_soar_and_routing(
1059                    &query,
1060                    &SoarConfig::full(),
1061                    IvfRoutingMode::TwoLevel,
1062                )
1063                .primary_cluster,
1064            best_leaf as u32
1065        );
1066    }
1067
1068    #[test]
1069    fn selective_soar_calibrates_to_a_storage_budget() {
1070        let centroids = CoarseCentroids {
1071            num_clusters: 2,
1072            dim: 2,
1073            centroids: vec![0.0, 0.0, 10.0, 0.0],
1074            version: 1,
1075            soar_config: None,
1076            routing_index: None,
1077        };
1078        let vectors: Vec<Vec<f32>> = (0..100)
1079            .map(|index| vec![index as f32 / 100.0, 0.0])
1080            .collect();
1081        let flat = vectors.iter().flatten().copied().collect::<Vec<_>>();
1082        let threshold = centroids.calibrate_selective_spill_threshold(
1083            &flat,
1084            vectors.len(),
1085            IvfRoutingMode::Flat,
1086            0.30,
1087        );
1088        let spilled = vectors
1089            .iter()
1090            .filter(|vector| squared_l2(vector, centroids.get_centroid(0)).sqrt() >= threshold)
1091            .count();
1092        assert!((29..=31).contains(&spilled), "{spilled}");
1093    }
1094
1095    #[test]
1096    fn selective_soar_never_exceeds_budget_when_residuals_tie() {
1097        let centroids = CoarseCentroids {
1098            num_clusters: 2,
1099            dim: 2,
1100            centroids: vec![0.0, 0.0, 10.0, 0.0],
1101            version: 1,
1102            soar_config: None,
1103            routing_index: None,
1104        };
1105        let vectors = [1.0f32, 0.0].repeat(100);
1106        let threshold = centroids.calibrate_selective_spill_threshold(
1107            &vectors,
1108            100,
1109            IvfRoutingMode::Flat,
1110            0.30,
1111        );
1112        let config = SoarConfig::new().threshold(threshold);
1113        let spilled = vectors
1114            .chunks_exact(2)
1115            .filter(|vector| centroids.assign_with_soar(vector, &config).is_spilled())
1116            .count();
1117
1118        assert!(threshold > 1.0);
1119        assert!(spilled <= 30, "{spilled}");
1120    }
1121
1122    #[test]
1123    fn selective_soar_preserves_boundary_query_candidate_recall_with_bounded_postings() {
1124        const DIM: usize = 16;
1125        const CLUSTERS: usize = 16;
1126        const MEMBERS_PER_CLUSTER: usize = 128;
1127        const TOP_K: usize = 20;
1128        const TARGET_SPILL: f32 = 0.30;
1129
1130        fn normalize(values: &mut [f32]) {
1131            let norm = values.iter().map(|value| value * value).sum::<f32>().sqrt();
1132            for value in values {
1133                *value /= norm;
1134            }
1135        }
1136
1137        let mut rng = rand::rngs::StdRng::seed_from_u64(0x50a4_5eed);
1138        let source_centers: Vec<Vec<f32>> = (0..CLUSTERS)
1139            .map(|_| {
1140                let mut center: Vec<f32> = (0..DIM).map(|_| rng.random::<f32>() - 0.5).collect();
1141                normalize(&mut center);
1142                center
1143            })
1144            .collect();
1145        let corpus: Vec<Vec<f32>> = source_centers
1146            .iter()
1147            .flat_map(|center| {
1148                (0..MEMBERS_PER_CLUSTER)
1149                    .map(|_| {
1150                        let mut noise: Vec<f32> =
1151                            (0..DIM).map(|_| rng.random::<f32>() - 0.5).collect();
1152                        normalize(&mut noise);
1153                        let mut vector: Vec<f32> = center
1154                            .iter()
1155                            .zip(noise)
1156                            .map(|(&value, noise)| value + 0.75 * noise)
1157                            .collect();
1158                        normalize(&mut vector);
1159                        vector
1160                    })
1161                    .collect::<Vec<_>>()
1162            })
1163            .collect();
1164
1165        let selective = CoarseCentroids::train(
1166            &CoarseConfig::new(DIM, CLUSTERS)
1167                .with_seed(0x1f4)
1168                .with_routing(IvfRoutingMode::Flat)
1169                .with_soar(SoarConfig::new().target_spill_fraction(TARGET_SPILL)),
1170            &corpus,
1171            "test",
1172        );
1173        // Share the exact trained codebook so the only variable is whether
1174        // documents receive selective secondary postings.
1175        let mut primary_only = selective.clone();
1176        primary_only.soar_config = None;
1177
1178        let primary_assignments: Vec<MultiAssignment> = corpus
1179            .iter()
1180            .map(|vector| primary_only.assign(vector))
1181            .collect();
1182        let selective_assignments: Vec<MultiAssignment> = corpus
1183            .iter()
1184            .map(|vector| selective.assign(vector))
1185            .collect();
1186        for (primary, spilled) in primary_assignments.iter().zip(&selective_assignments) {
1187            assert_eq!(
1188                primary.primary_cluster, spilled.primary_cluster,
1189                "SOAR policy must not change the primary posting"
1190            );
1191        }
1192
1193        let spilled = selective_assignments
1194            .iter()
1195            .filter(|assignment| assignment.is_spilled())
1196            .count();
1197        let posting_factor = selective_assignments
1198            .iter()
1199            .map(MultiAssignment::num_assignments)
1200            .sum::<usize>() as f32
1201            / corpus.len() as f32;
1202        let spill_budget = (corpus.len() as f32 * TARGET_SPILL).round() as usize;
1203        assert!(
1204            spilled > 0,
1205            "the smoke corpus must exercise selective spilling"
1206        );
1207        assert!(
1208            spilled <= spill_budget,
1209            "{spilled} spilled vectors exceeded the calibrated budget of {spill_budget}"
1210        );
1211        assert!(
1212            posting_factor <= 1.0 + TARGET_SPILL + f32::EPSILON,
1213            "posting amplification {posting_factor:.4} exceeded the 1.30 calibration target"
1214        );
1215
1216        // Midpoints between each learned centroid and its nearest peer stress
1217        // the exact partition boundaries where a single probe loses the most
1218        // candidates and selective secondary postings should help.
1219        let queries: Vec<Vec<f32>> = (0..selective.num_clusters)
1220            .map(|left| {
1221                let left_centroid = selective.get_centroid(left);
1222                let right = (0..selective.num_clusters)
1223                    .filter(|&candidate| candidate != left)
1224                    .min_by(|&a, &b| {
1225                        squared_l2(left_centroid, selective.get_centroid(a))
1226                            .total_cmp(&squared_l2(left_centroid, selective.get_centroid(b)))
1227                            .then_with(|| a.cmp(&b))
1228                    })
1229                    .unwrap();
1230                let mut query: Vec<f32> = left_centroid
1231                    .iter()
1232                    .zip(selective.get_centroid(right))
1233                    .map(|(&a, &b)| 0.51 * a + 0.49 * b)
1234                    .collect();
1235                normalize(&mut query);
1236                query
1237            })
1238            .collect();
1239
1240        let mut gained_queries = 0usize;
1241        for nprobe in [1, 2] {
1242            let mut primary_hits = 0usize;
1243            let mut selective_hits = 0usize;
1244            for query in &queries {
1245                let primary_plan = primary_only.probe(query, nprobe, IvfRoutingMode::Flat);
1246                let selective_plan = selective.probe(query, nprobe, IvfRoutingMode::Flat);
1247                assert_eq!(
1248                    primary_plan.cluster_ids, selective_plan.cluster_ids,
1249                    "SOAR must not alter query routing"
1250                );
1251
1252                let mut truth: Vec<(usize, f32)> = corpus
1253                    .iter()
1254                    .enumerate()
1255                    .map(|(document, vector)| (document, squared_l2(query, vector)))
1256                    .collect();
1257                truth.select_nth_unstable_by(TOP_K, |left, right| {
1258                    left.1
1259                        .total_cmp(&right.1)
1260                        .then_with(|| left.0.cmp(&right.0))
1261                });
1262                truth.truncate(TOP_K);
1263
1264                let query_primary_hits = truth
1265                    .iter()
1266                    .filter(|&&(document, _)| {
1267                        primary_assignments[document]
1268                            .all_clusters()
1269                            .any(|cluster| primary_plan.cluster_ids.contains(&cluster))
1270                    })
1271                    .count();
1272                let query_selective_hits = truth
1273                    .iter()
1274                    .filter(|&&(document, _)| {
1275                        selective_assignments[document]
1276                            .all_clusters()
1277                            .any(|cluster| selective_plan.cluster_ids.contains(&cluster))
1278                    })
1279                    .count();
1280                primary_hits += query_primary_hits;
1281                selective_hits += query_selective_hits;
1282                gained_queries += usize::from(query_selective_hits > query_primary_hits);
1283            }
1284
1285            let denominator = (queries.len() * TOP_K) as f32;
1286            let primary_recall = primary_hits as f32 / denominator;
1287            let selective_recall = selective_hits as f32 / denominator;
1288            assert!(
1289                selective_recall + 0.005 >= primary_recall,
1290                "selective SOAR candidate recall regressed at nprobe={nprobe}: \
1291                 {selective_recall:.4} vs {primary_recall:.4}"
1292            );
1293            if nprobe == 1 {
1294                assert!(
1295                    selective_recall >= primary_recall + 0.01,
1296                    "selective SOAR must recover boundary candidates at nprobe=1: \
1297                     {selective_recall:.4} vs {primary_recall:.4}"
1298                );
1299            }
1300        }
1301        assert!(
1302            gained_queries > 0,
1303            "boundary-query smoke did not exercise a selective SOAR recall gain"
1304        );
1305    }
1306
1307    #[test]
1308    fn soar_does_not_choose_an_arbitrarily_distant_orthogonal_centroid() {
1309        let centroids = CoarseCentroids {
1310            num_clusters: 3,
1311            dim: 2,
1312            // For x=[0,0], cluster 0 is primary with r=[1,0].
1313            // Cluster 1 is perfectly orthogonal but extremely distant.
1314            // Cluster 2 is slightly farther than the primary and parallel:
1315            // its complete SOAR loss is 1.21 + 1.21 = 2.42.
1316            centroids: vec![-1.0, 0.0, 0.0, -100.0, 1.1, 0.0],
1317            version: 1,
1318            soar_config: None,
1319            routing_index: None,
1320        };
1321
1322        let assignment = centroids.assign_with_soar(&[0.0, 0.0], &SoarConfig::full());
1323        assert_eq!(assignment.primary_cluster, 0);
1324        assert_eq!(assignment.secondary_clusters, vec![2]);
1325    }
1326
1327    #[test]
1328    fn selective_soar_rejects_a_far_secondary_residual() {
1329        let centroids = CoarseCentroids {
1330            num_clusters: 2,
1331            dim: 2,
1332            centroids: vec![0.0, 0.0, 10.0, 0.0],
1333            version: 1,
1334            soar_config: None,
1335            routing_index: None,
1336        };
1337        let config = SoarConfig::new().threshold(0.0);
1338
1339        // Primary squared distance is 1; the only secondary is 81 away.
1340        // Selective SOAR must not create a low-quality far-leaf posting.
1341        let assignment = centroids.assign_with_soar(&[1.0, 0.0], &config);
1342        assert_eq!(assignment.primary_cluster, 0);
1343        assert!(assignment.secondary_clusters.is_empty());
1344    }
1345
1346    #[test]
1347    fn selective_soar_keeps_a_comparable_boundary_secondary() {
1348        let centroids = CoarseCentroids {
1349            num_clusters: 2,
1350            dim: 2,
1351            centroids: vec![0.0, 0.0, 2.0, 0.0],
1352            version: 1,
1353            soar_config: None,
1354            routing_index: None,
1355        };
1356        let config = SoarConfig::new().threshold(0.0);
1357
1358        // The point is close to the Voronoi boundary: primary and secondary
1359        // squared distances are 0.82 and 1.22, comfortably within the gate.
1360        let assignment = centroids.assign_with_soar(&[0.9, 0.1], &config);
1361        assert_eq!(assignment.primary_cluster, 0);
1362        assert_eq!(assignment.secondary_clusters, vec![1]);
1363    }
1364
1365    #[test]
1366    fn soar_secondary_ties_are_ordered_by_cluster_id_and_capped_to_one() {
1367        let centroids = CoarseCentroids {
1368            num_clusters: 3,
1369            dim: 2,
1370            centroids: vec![0.0, 0.0, 1.0, 0.0, -1.0, 0.0],
1371            version: 1,
1372            soar_config: None,
1373            routing_index: None,
1374        };
1375        let config = SoarConfig {
1376            num_secondary: 2,
1377            selective: false,
1378            spill_threshold: 0.0,
1379        };
1380
1381        let assignment = centroids.assign_with_soar(&[0.0, 0.0], &config);
1382        assert_eq!(assignment.primary_cluster, 0);
1383        assert_eq!(assignment.secondary_clusters, vec![1]);
1384    }
1385
1386    #[test]
1387    fn test_serialization() {
1388        let dim = 16;
1389        let n = 50;
1390        let num_clusters = 4;
1391
1392        let mut rng = rand::rngs::StdRng::seed_from_u64(789);
1393        let vectors: Vec<Vec<f32>> = (0..n)
1394            .map(|_| (0..dim).map(|_| rng.random::<f32>()).collect())
1395            .collect();
1396
1397        let config = CoarseConfig::new(dim, num_clusters);
1398        let centroids = CoarseCentroids::train(&config, &vectors, "test");
1399
1400        // Serialize and deserialize
1401        let bytes = bincode::serde::encode_to_vec(&centroids, bincode::config::standard()).unwrap();
1402        let (loaded, consumed): (CoarseCentroids, usize) =
1403            bincode::serde::decode_from_slice(&bytes, bincode::config::standard()).unwrap();
1404        assert_eq!(consumed, bytes.len());
1405
1406        assert_eq!(loaded.num_clusters, centroids.num_clusters);
1407        assert_eq!(loaded.dim, centroids.dim);
1408        assert_eq!(loaded.centroids.len(), centroids.centroids.len());
1409    }
1410
1411    #[test]
1412    fn persisted_hnsw_and_two_level_routers_are_valid() {
1413        let dim = 4;
1414        let mut rng = rand::rngs::StdRng::seed_from_u64(991);
1415        let vectors: Vec<Vec<f32>> = (0..256)
1416            .map(|_| (0..dim).map(|_| rng.random::<f32>()).collect())
1417            .collect();
1418
1419        for routing in [IvfRoutingMode::Hnsw, IvfRoutingMode::TwoLevel] {
1420            let trained = CoarseCentroids::train(
1421                &CoarseConfig::new(dim, 16).with_routing(routing),
1422                &vectors,
1423                "test",
1424            );
1425            trained.validate_routing(routing).unwrap();
1426            let plan = trained.probe(&vectors[0], 8, routing);
1427            assert_eq!(plan.cluster_ids.len(), 8);
1428            assert!(
1429                plan.cluster_ids
1430                    .iter()
1431                    .all(|&cluster| cluster < trained.num_clusters)
1432            );
1433
1434            let bytes =
1435                bincode::serde::encode_to_vec(&trained, bincode::config::standard()).unwrap();
1436            let (loaded, consumed): (CoarseCentroids, usize) =
1437                bincode::serde::decode_from_slice(&bytes, bincode::config::standard()).unwrap();
1438            assert_eq!(consumed, bytes.len());
1439            loaded.validate_routing(routing).unwrap();
1440        }
1441    }
1442}