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