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