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