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    left.iter()
820        .zip(right)
821        .map(|(&a, &b)| {
822            let delta = a - b;
823            delta * delta
824        })
825        .sum()
826}
827
828#[inline]
829fn threshold_strictly_above(value: f32) -> f32 {
830    if !value.is_finite() {
831        return f32::INFINITY;
832    }
833    // A single ULP is not sufficient near zero because the assignment path
834    // compares squared norms and a subnormal threshold can square back to
835    // zero. A small relative-or-absolute margin remains negligible for
836    // normalized vectors while guaranteeing a strictly larger squared bound.
837    let next = value + value.abs().max(1.0) * (4.0 * f32::EPSILON);
838    if next > value { next } else { f32::INFINITY }
839}
840
841#[inline]
842fn soar_build_candidate_budget(num_secondary: usize, num_clusters: usize) -> usize {
843    num_secondary
844        .saturating_add(1)
845        .saturating_mul(64)
846        .max(BUILD_ASSIGNMENT_CANDIDATES)
847        .min(num_clusters)
848}
849
850#[inline]
851fn soar_secondary_loss(
852    vector: &[f32],
853    secondary_centroid: &[f32],
854    primary_residual: &[f32],
855    primary_residual_norm_sq: f32,
856    secondary_residual_norm_sq: f32,
857) -> f32 {
858    let residual_dot = vector
859        .iter()
860        .zip(secondary_centroid)
861        .zip(primary_residual)
862        .map(|((&value, &centroid), &primary)| primary * (value - centroid))
863        .sum::<f32>();
864
865    // A zero primary residual already has no correlated score error. Define
866    // its projection penalty as zero rather than producing 0/0.
867    let projection_norm_sq = if primary_residual_norm_sq > 0.0 {
868        residual_dot * residual_dot / primary_residual_norm_sq
869    } else {
870        0.0
871    };
872    secondary_residual_norm_sq + SOAR_LAMBDA * projection_norm_sq
873}
874
875#[inline]
876fn scored_cluster_order(left: &(u32, f32), right: &(u32, f32)) -> std::cmp::Ordering {
877    left.1
878        .total_cmp(&right.1)
879        .then_with(|| left.0.cmp(&right.0))
880}
881
882#[cfg(test)]
883mod tests {
884    use super::*;
885    use rand::prelude::*;
886
887    #[test]
888    fn test_coarse_centroids_basic() {
889        let dim = 64;
890        let n = 1000;
891        let num_clusters = 16;
892
893        let mut rng = rand::rngs::StdRng::seed_from_u64(42);
894        let vectors: Vec<Vec<f32>> = (0..n)
895            .map(|_| (0..dim).map(|_| rng.random::<f32>() - 0.5).collect())
896            .collect();
897
898        let config = CoarseConfig::new(dim, num_clusters);
899        let centroids = CoarseCentroids::train(&config, &vectors, "test");
900
901        assert_eq!(centroids.num_clusters, num_clusters as u32);
902        assert_eq!(centroids.dim, dim);
903    }
904
905    #[test]
906    fn contiguous_training_matches_row_wrapper() {
907        let dim = 8;
908        let vectors: Vec<Vec<f32>> = (0..128)
909            .map(|row| {
910                (0..dim)
911                    .map(|column| ((row * 31 + column * 17) % 101) as f32 / 101.0)
912                    .collect()
913            })
914            .collect();
915        let flat = vectors.iter().flatten().copied().collect::<Vec<_>>();
916        let config = CoarseConfig::new(dim, 12)
917            .with_seed(91)
918            .with_routing(IvfRoutingMode::TwoLevel);
919
920        let rows = CoarseCentroids::train(&config, &vectors, "test");
921        let contiguous = CoarseCentroids::train_contiguous(&config, &flat, vectors.len(), "test");
922
923        assert_eq!(rows.centroids, contiguous.centroids);
924        assert_eq!(
925            bincode::serde::encode_to_vec(&rows.routing_index, bincode::config::standard())
926                .unwrap(),
927            bincode::serde::encode_to_vec(&contiguous.routing_index, bincode::config::standard())
928                .unwrap(),
929        );
930    }
931
932    #[test]
933    fn test_find_nearest() {
934        let dim = 32;
935        let n = 500;
936        let num_clusters = 8;
937
938        let mut rng = rand::rngs::StdRng::seed_from_u64(123);
939        let vectors: Vec<Vec<f32>> = (0..n)
940            .map(|_| (0..dim).map(|_| rng.random::<f32>()).collect())
941            .collect();
942
943        let config = CoarseConfig::new(dim, num_clusters);
944        let centroids = CoarseCentroids::train(&config, &vectors, "test");
945
946        // Test that find_nearest returns valid cluster IDs
947        for v in &vectors {
948            let cluster = centroids.find_nearest(v);
949            assert!(cluster < centroids.num_clusters);
950        }
951    }
952
953    #[test]
954    fn scaled_l2_probes_keep_distinct_cache_identities() {
955        let centroids = CoarseCentroids {
956            num_clusters: 2,
957            dim: 2,
958            centroids: vec![1.0, 0.0, 10.0, 0.0],
959            version: 7,
960            soar_config: None,
961            routing_index: None,
962        };
963
964        let near = centroids.probe(&[1.0, 0.0], 1, IvfRoutingMode::Flat);
965        let scaled = centroids.probe(&[100.0, 0.0], 1, IvfRoutingMode::Flat);
966
967        assert_eq!(&*near.cluster_ids, &[0]);
968        assert_eq!(&*scaled.cluster_ids, &[1]);
969        assert_ne!(near.request_fingerprint, scaled.request_fingerprint);
970    }
971
972    #[test]
973    fn test_soar_assignment() {
974        let dim = 32;
975        let n = 100;
976        let num_clusters = 8;
977
978        let mut rng = rand::rngs::StdRng::seed_from_u64(456);
979        let vectors: Vec<Vec<f32>> = (0..n)
980            .map(|_| (0..dim).map(|_| rng.random::<f32>()).collect())
981            .collect();
982
983        let soar_config = SoarConfig {
984            num_secondary: 2,
985            selective: false,
986            spill_threshold: 0.0,
987        };
988        let config = CoarseConfig::new(dim, num_clusters).with_soar(soar_config);
989        let centroids = CoarseCentroids::train(&config, &vectors, "test");
990
991        // Test SOAR assignment
992        let assignment = centroids.assign(&vectors[0]);
993        assert!(assignment.primary_cluster < centroids.num_clusters);
994        assert_eq!(centroids.soar_config.as_ref().unwrap().num_secondary, 1);
995        assert_eq!(assignment.secondary_clusters.len(), 1);
996
997        // Secondary clusters should be different from primary
998        for &sec in &assignment.secondary_clusters {
999            assert_ne!(sec, assignment.primary_cluster);
1000        }
1001    }
1002
1003    #[test]
1004    fn soar_loss_includes_distortion_and_normalized_projection() {
1005        let vector = [0.0, 0.0];
1006        let primary_residual = [2.0, 0.0];
1007        let secondary_centroid = [-3.0, -4.0];
1008
1009        // r' = [3, 4], so ||r'||² = 25 and
1010        // ||proj_r(r')||² = <r,r'>² / ||r||² = 36 / 4 = 9.
1011        let loss = soar_secondary_loss(&vector, &secondary_centroid, &primary_residual, 4.0, 25.0);
1012        assert!((loss - 34.0).abs() <= f32::EPSILON);
1013    }
1014
1015    #[test]
1016    fn soar_routing_keeps_an_oversampled_secondary_candidate_pool() {
1017        assert_eq!(soar_build_candidate_budget(1, 1_000), 128);
1018        assert_eq!(soar_build_candidate_budget(2, 1_000), 192);
1019        assert_eq!(soar_build_candidate_budget(8, 64), 64);
1020    }
1021
1022    #[test]
1023    fn two_level_build_assignment_checks_four_parents_for_primary_and_soar() {
1024        let leaves_per_parent = 512;
1025        let children: Vec<Vec<u32>> = (0..4)
1026            .map(|parent| {
1027                let first = parent * leaves_per_parent;
1028                (first..first + leaves_per_parent)
1029                    .map(|leaf| leaf as u32)
1030                    .collect()
1031            })
1032            .collect();
1033        let mut leaf_centroids = vec![1.0; 4 * leaves_per_parent];
1034        let best_leaf = 3 * leaves_per_parent;
1035        leaf_centroids[best_leaf] = 0.0;
1036        let centroids = CoarseCentroids {
1037            num_clusters: leaf_centroids.len() as u32,
1038            dim: 1,
1039            centroids: leaf_centroids,
1040            version: 1,
1041            soar_config: None,
1042            routing_index: Some(FloatCentroidRouter::TwoLevel {
1043                parent_centroids: vec![0.0, 10.0, 20.0, 30.0],
1044                topology: IvfRoutingTopology::from_children(&children),
1045            }),
1046        };
1047
1048        let query = [0.0];
1049        assert_eq!(
1050            &*centroids
1051                .probe(&query, 1, IvfRoutingMode::TwoLevel)
1052                .cluster_ids,
1053            &[0]
1054        );
1055        assert_eq!(
1056            centroids
1057                .assign_with_routing(&query, IvfRoutingMode::TwoLevel)
1058                .primary_cluster,
1059            best_leaf as u32
1060        );
1061        assert_eq!(
1062            centroids
1063                .assign_with_soar_and_routing(
1064                    &query,
1065                    &SoarConfig::full(),
1066                    IvfRoutingMode::TwoLevel,
1067                )
1068                .primary_cluster,
1069            best_leaf as u32
1070        );
1071    }
1072
1073    #[test]
1074    fn selective_soar_calibrates_to_a_storage_budget() {
1075        let centroids = CoarseCentroids {
1076            num_clusters: 2,
1077            dim: 2,
1078            centroids: vec![0.0, 0.0, 10.0, 0.0],
1079            version: 1,
1080            soar_config: None,
1081            routing_index: None,
1082        };
1083        let vectors: Vec<Vec<f32>> = (0..100)
1084            .map(|index| vec![index as f32 / 100.0, 0.0])
1085            .collect();
1086        let flat = vectors.iter().flatten().copied().collect::<Vec<_>>();
1087        let threshold = centroids.calibrate_selective_spill_threshold(
1088            &flat,
1089            vectors.len(),
1090            IvfRoutingMode::Flat,
1091            0.30,
1092        );
1093        let spilled = vectors
1094            .iter()
1095            .filter(|vector| squared_l2(vector, centroids.get_centroid(0)).sqrt() >= threshold)
1096            .count();
1097        assert!((29..=31).contains(&spilled), "{spilled}");
1098    }
1099
1100    #[test]
1101    fn selective_soar_never_exceeds_budget_when_residuals_tie() {
1102        let centroids = CoarseCentroids {
1103            num_clusters: 2,
1104            dim: 2,
1105            centroids: vec![0.0, 0.0, 10.0, 0.0],
1106            version: 1,
1107            soar_config: None,
1108            routing_index: None,
1109        };
1110        let vectors = [1.0f32, 0.0].repeat(100);
1111        let threshold = centroids.calibrate_selective_spill_threshold(
1112            &vectors,
1113            100,
1114            IvfRoutingMode::Flat,
1115            0.30,
1116        );
1117        let config = SoarConfig::new().threshold(threshold);
1118        let spilled = vectors
1119            .chunks_exact(2)
1120            .filter(|vector| centroids.assign_with_soar(vector, &config).is_spilled())
1121            .count();
1122
1123        assert!(threshold > 1.0);
1124        assert!(spilled <= 30, "{spilled}");
1125    }
1126
1127    #[test]
1128    fn selective_soar_preserves_boundary_query_candidate_recall_with_bounded_postings() {
1129        const DIM: usize = 16;
1130        const CLUSTERS: usize = 16;
1131        const MEMBERS_PER_CLUSTER: usize = 128;
1132        const TOP_K: usize = 20;
1133        const TARGET_SPILL: f32 = 0.30;
1134
1135        fn normalize(values: &mut [f32]) {
1136            let norm = values.iter().map(|value| value * value).sum::<f32>().sqrt();
1137            for value in values {
1138                *value /= norm;
1139            }
1140        }
1141
1142        let mut rng = rand::rngs::StdRng::seed_from_u64(0x50a4_5eed);
1143        let source_centers: Vec<Vec<f32>> = (0..CLUSTERS)
1144            .map(|_| {
1145                let mut center: Vec<f32> = (0..DIM).map(|_| rng.random::<f32>() - 0.5).collect();
1146                normalize(&mut center);
1147                center
1148            })
1149            .collect();
1150        let corpus: Vec<Vec<f32>> = source_centers
1151            .iter()
1152            .flat_map(|center| {
1153                (0..MEMBERS_PER_CLUSTER)
1154                    .map(|_| {
1155                        let mut noise: Vec<f32> =
1156                            (0..DIM).map(|_| rng.random::<f32>() - 0.5).collect();
1157                        normalize(&mut noise);
1158                        let mut vector: Vec<f32> = center
1159                            .iter()
1160                            .zip(noise)
1161                            .map(|(&value, noise)| value + 0.75 * noise)
1162                            .collect();
1163                        normalize(&mut vector);
1164                        vector
1165                    })
1166                    .collect::<Vec<_>>()
1167            })
1168            .collect();
1169
1170        let selective = CoarseCentroids::train(
1171            &CoarseConfig::new(DIM, CLUSTERS)
1172                .with_seed(0x1f4)
1173                .with_routing(IvfRoutingMode::Flat)
1174                .with_soar(SoarConfig::new().target_spill_fraction(TARGET_SPILL)),
1175            &corpus,
1176            "test",
1177        );
1178        // Share the exact trained codebook so the only variable is whether
1179        // documents receive selective secondary postings.
1180        let mut primary_only = selective.clone();
1181        primary_only.soar_config = None;
1182
1183        let primary_assignments: Vec<MultiAssignment> = corpus
1184            .iter()
1185            .map(|vector| primary_only.assign(vector))
1186            .collect();
1187        let selective_assignments: Vec<MultiAssignment> = corpus
1188            .iter()
1189            .map(|vector| selective.assign(vector))
1190            .collect();
1191        for (primary, spilled) in primary_assignments.iter().zip(&selective_assignments) {
1192            assert_eq!(
1193                primary.primary_cluster, spilled.primary_cluster,
1194                "SOAR policy must not change the primary posting"
1195            );
1196        }
1197
1198        let spilled = selective_assignments
1199            .iter()
1200            .filter(|assignment| assignment.is_spilled())
1201            .count();
1202        let posting_factor = selective_assignments
1203            .iter()
1204            .map(MultiAssignment::num_assignments)
1205            .sum::<usize>() as f32
1206            / corpus.len() as f32;
1207        let spill_budget = (corpus.len() as f32 * TARGET_SPILL).round() as usize;
1208        assert!(
1209            spilled > 0,
1210            "the smoke corpus must exercise selective spilling"
1211        );
1212        assert!(
1213            spilled <= spill_budget,
1214            "{spilled} spilled vectors exceeded the calibrated budget of {spill_budget}"
1215        );
1216        assert!(
1217            posting_factor <= 1.0 + TARGET_SPILL + f32::EPSILON,
1218            "posting amplification {posting_factor:.4} exceeded the 1.30 calibration target"
1219        );
1220
1221        // Midpoints between each learned centroid and its nearest peer stress
1222        // the exact partition boundaries where a single probe loses the most
1223        // candidates and selective secondary postings should help.
1224        let queries: Vec<Vec<f32>> = (0..selective.num_clusters)
1225            .map(|left| {
1226                let left_centroid = selective.get_centroid(left);
1227                let right = (0..selective.num_clusters)
1228                    .filter(|&candidate| candidate != left)
1229                    .min_by(|&a, &b| {
1230                        squared_l2(left_centroid, selective.get_centroid(a))
1231                            .total_cmp(&squared_l2(left_centroid, selective.get_centroid(b)))
1232                            .then_with(|| a.cmp(&b))
1233                    })
1234                    .unwrap();
1235                let mut query: Vec<f32> = left_centroid
1236                    .iter()
1237                    .zip(selective.get_centroid(right))
1238                    .map(|(&a, &b)| 0.51 * a + 0.49 * b)
1239                    .collect();
1240                normalize(&mut query);
1241                query
1242            })
1243            .collect();
1244
1245        let mut gained_queries = 0usize;
1246        for nprobe in [1, 2] {
1247            let mut primary_hits = 0usize;
1248            let mut selective_hits = 0usize;
1249            for query in &queries {
1250                let primary_plan = primary_only.probe(query, nprobe, IvfRoutingMode::Flat);
1251                let selective_plan = selective.probe(query, nprobe, IvfRoutingMode::Flat);
1252                assert_eq!(
1253                    primary_plan.cluster_ids, selective_plan.cluster_ids,
1254                    "SOAR must not alter query routing"
1255                );
1256
1257                let mut truth: Vec<(usize, f32)> = corpus
1258                    .iter()
1259                    .enumerate()
1260                    .map(|(document, vector)| (document, squared_l2(query, vector)))
1261                    .collect();
1262                truth.select_nth_unstable_by(TOP_K, |left, right| {
1263                    left.1
1264                        .total_cmp(&right.1)
1265                        .then_with(|| left.0.cmp(&right.0))
1266                });
1267                truth.truncate(TOP_K);
1268
1269                let query_primary_hits = truth
1270                    .iter()
1271                    .filter(|&&(document, _)| {
1272                        primary_assignments[document]
1273                            .all_clusters()
1274                            .any(|cluster| primary_plan.cluster_ids.contains(&cluster))
1275                    })
1276                    .count();
1277                let query_selective_hits = truth
1278                    .iter()
1279                    .filter(|&&(document, _)| {
1280                        selective_assignments[document]
1281                            .all_clusters()
1282                            .any(|cluster| selective_plan.cluster_ids.contains(&cluster))
1283                    })
1284                    .count();
1285                primary_hits += query_primary_hits;
1286                selective_hits += query_selective_hits;
1287                gained_queries += usize::from(query_selective_hits > query_primary_hits);
1288            }
1289
1290            let denominator = (queries.len() * TOP_K) as f32;
1291            let primary_recall = primary_hits as f32 / denominator;
1292            let selective_recall = selective_hits as f32 / denominator;
1293            assert!(
1294                selective_recall + 0.005 >= primary_recall,
1295                "selective SOAR candidate recall regressed at nprobe={nprobe}: \
1296                 {selective_recall:.4} vs {primary_recall:.4}"
1297            );
1298            if nprobe == 1 {
1299                assert!(
1300                    selective_recall >= primary_recall + 0.01,
1301                    "selective SOAR must recover boundary candidates at nprobe=1: \
1302                     {selective_recall:.4} vs {primary_recall:.4}"
1303                );
1304            }
1305        }
1306        assert!(
1307            gained_queries > 0,
1308            "boundary-query smoke did not exercise a selective SOAR recall gain"
1309        );
1310    }
1311
1312    #[test]
1313    fn soar_does_not_choose_an_arbitrarily_distant_orthogonal_centroid() {
1314        let centroids = CoarseCentroids {
1315            num_clusters: 3,
1316            dim: 2,
1317            // For x=[0,0], cluster 0 is primary with r=[1,0].
1318            // Cluster 1 is perfectly orthogonal but extremely distant.
1319            // Cluster 2 is slightly farther than the primary and parallel:
1320            // its complete SOAR loss is 1.21 + 1.21 = 2.42.
1321            centroids: vec![-1.0, 0.0, 0.0, -100.0, 1.1, 0.0],
1322            version: 1,
1323            soar_config: None,
1324            routing_index: None,
1325        };
1326
1327        let assignment = centroids.assign_with_soar(&[0.0, 0.0], &SoarConfig::full());
1328        assert_eq!(assignment.primary_cluster, 0);
1329        assert_eq!(assignment.secondary_clusters, vec![2]);
1330    }
1331
1332    #[test]
1333    fn selective_soar_rejects_a_far_secondary_residual() {
1334        let centroids = CoarseCentroids {
1335            num_clusters: 2,
1336            dim: 2,
1337            centroids: vec![0.0, 0.0, 10.0, 0.0],
1338            version: 1,
1339            soar_config: None,
1340            routing_index: None,
1341        };
1342        let config = SoarConfig::new().threshold(0.0);
1343
1344        // Primary squared distance is 1; the only secondary is 81 away.
1345        // Selective SOAR must not create a low-quality far-leaf posting.
1346        let assignment = centroids.assign_with_soar(&[1.0, 0.0], &config);
1347        assert_eq!(assignment.primary_cluster, 0);
1348        assert!(assignment.secondary_clusters.is_empty());
1349    }
1350
1351    #[test]
1352    fn selective_soar_keeps_a_comparable_boundary_secondary() {
1353        let centroids = CoarseCentroids {
1354            num_clusters: 2,
1355            dim: 2,
1356            centroids: vec![0.0, 0.0, 2.0, 0.0],
1357            version: 1,
1358            soar_config: None,
1359            routing_index: None,
1360        };
1361        let config = SoarConfig::new().threshold(0.0);
1362
1363        // The point is close to the Voronoi boundary: primary and secondary
1364        // squared distances are 0.82 and 1.22, comfortably within the gate.
1365        let assignment = centroids.assign_with_soar(&[0.9, 0.1], &config);
1366        assert_eq!(assignment.primary_cluster, 0);
1367        assert_eq!(assignment.secondary_clusters, vec![1]);
1368    }
1369
1370    #[test]
1371    fn soar_secondary_ties_are_ordered_by_cluster_id_and_capped_to_one() {
1372        let centroids = CoarseCentroids {
1373            num_clusters: 3,
1374            dim: 2,
1375            centroids: vec![0.0, 0.0, 1.0, 0.0, -1.0, 0.0],
1376            version: 1,
1377            soar_config: None,
1378            routing_index: None,
1379        };
1380        let config = SoarConfig {
1381            num_secondary: 2,
1382            selective: false,
1383            spill_threshold: 0.0,
1384        };
1385
1386        let assignment = centroids.assign_with_soar(&[0.0, 0.0], &config);
1387        assert_eq!(assignment.primary_cluster, 0);
1388        assert_eq!(assignment.secondary_clusters, vec![1]);
1389    }
1390
1391    #[test]
1392    fn test_serialization() {
1393        let dim = 16;
1394        let n = 50;
1395        let num_clusters = 4;
1396
1397        let mut rng = rand::rngs::StdRng::seed_from_u64(789);
1398        let vectors: Vec<Vec<f32>> = (0..n)
1399            .map(|_| (0..dim).map(|_| rng.random::<f32>()).collect())
1400            .collect();
1401
1402        let config = CoarseConfig::new(dim, num_clusters);
1403        let centroids = CoarseCentroids::train(&config, &vectors, "test");
1404
1405        // Serialize and deserialize
1406        let bytes = bincode::serde::encode_to_vec(&centroids, bincode::config::standard()).unwrap();
1407        let (loaded, consumed): (CoarseCentroids, usize) =
1408            bincode::serde::decode_from_slice(&bytes, bincode::config::standard()).unwrap();
1409        assert_eq!(consumed, bytes.len());
1410
1411        assert_eq!(loaded.num_clusters, centroids.num_clusters);
1412        assert_eq!(loaded.dim, centroids.dim);
1413        assert_eq!(loaded.centroids.len(), centroids.centroids.len());
1414    }
1415
1416    #[test]
1417    fn persisted_hnsw_and_two_level_routers_are_valid() {
1418        let dim = 4;
1419        let mut rng = rand::rngs::StdRng::seed_from_u64(991);
1420        let vectors: Vec<Vec<f32>> = (0..256)
1421            .map(|_| (0..dim).map(|_| rng.random::<f32>()).collect())
1422            .collect();
1423
1424        for routing in [IvfRoutingMode::Hnsw, IvfRoutingMode::TwoLevel] {
1425            let trained = CoarseCentroids::train(
1426                &CoarseConfig::new(dim, 16).with_routing(routing),
1427                &vectors,
1428                "test",
1429            );
1430            trained.validate_routing(routing).unwrap();
1431            let plan = trained.probe(&vectors[0], 8, routing);
1432            assert_eq!(plan.cluster_ids.len(), 8);
1433            assert!(
1434                plan.cluster_ids
1435                    .iter()
1436                    .all(|&cluster| cluster < trained.num_clusters)
1437            );
1438
1439            let bytes =
1440                bincode::serde::encode_to_vec(&trained, bincode::config::standard()).unwrap();
1441            let (loaded, consumed): (CoarseCentroids, usize) =
1442                bincode::serde::decode_from_slice(&bytes, bincode::config::standard()).unwrap();
1443            assert_eq!(consumed, bytes.len());
1444            loaded.validate_routing(routing).unwrap();
1445        }
1446    }
1447}