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, parent_probe_count,
11    routing_parent_count, select_best, select_best_candidates,
12};
13use super::soar::{MultiAssignment, SoarConfig};
14use crate::dsl::IvfRoutingMode;
15
16/// Configuration for coarse quantizer
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct CoarseConfig {
19    /// Number of clusters
20    pub num_clusters: usize,
21    /// Vector dimension
22    pub dim: usize,
23    /// Maximum k-means iterations
24    pub max_iters: usize,
25    /// Random seed for reproducibility
26    pub seed: u64,
27    /// SOAR configuration (optional)
28    pub soar: Option<SoarConfig>,
29    /// Flat, two-level, or HNSW routing. Auto chooses from the final leaf count.
30    pub routing: IvfRoutingMode,
31}
32
33impl CoarseConfig {
34    pub fn new(dim: usize, num_clusters: usize) -> Self {
35        Self {
36            num_clusters,
37            dim,
38            max_iters: 25,
39            seed: 42,
40            soar: None,
41            routing: IvfRoutingMode::Auto,
42        }
43    }
44
45    pub fn with_soar(mut self, config: SoarConfig) -> Self {
46        self.soar = Some(config);
47        self
48    }
49
50    pub fn with_seed(mut self, seed: u64) -> Self {
51        self.seed = seed;
52        self
53    }
54
55    pub fn with_max_iters(mut self, iters: usize) -> Self {
56        self.max_iters = iters;
57        self
58    }
59
60    pub fn with_routing(mut self, routing: IvfRoutingMode) -> Self {
61        self.routing = routing;
62        self
63    }
64}
65
66/// Coarse centroids for IVF - trained once, shared across all segments
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct CoarseCentroids {
69    /// Number of clusters
70    pub num_clusters: u32,
71    /// Vector dimension
72    pub dim: usize,
73    /// Centroids stored as flat array (num_clusters × dim)
74    pub centroids: Vec<f32>,
75    /// Version for compatibility checking during merge
76    pub version: u64,
77    /// SOAR configuration (if enabled)
78    pub soar_config: Option<SoarConfig>,
79    /// Persisted parent centroids and topology for sublinear leaf routing.
80    pub(crate) routing_index: Option<FloatCentroidRouter>,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub(crate) enum FloatCentroidRouter {
85    TwoLevel {
86        parent_centroids: Vec<f32>,
87        topology: IvfRoutingTopology,
88    },
89    Hnsw(HnswRoutingGraph),
90}
91
92impl CoarseCentroids {
93    /// Train coarse centroids using k-means algorithm
94    ///
95    /// Uses deterministic k-means++ seeding and Lloyd refinement.
96    pub fn train(config: &CoarseConfig, vectors: &[Vec<f32>]) -> Self {
97        assert!(!vectors.is_empty(), "Cannot train on empty vector set");
98        assert!(config.num_clusters > 0, "Need at least 1 cluster");
99        assert!(vectors.iter().all(|vector| vector.len() == config.dim));
100
101        let actual_clusters = config.num_clusters.min(vectors.len());
102        let (centroids, routing_index) =
103            match effective_routing_mode(config.routing, actual_clusters) {
104                IvfRoutingMode::TwoLevel => {
105                    let (leaves, router) =
106                        Self::train_hierarchical(config, vectors, actual_clusters);
107                    (leaves, Some(router))
108                }
109                IvfRoutingMode::Hnsw => {
110                    let leaves = if actual_clusters >= HNSW_AUTO_THRESHOLD {
111                        Self::train_hierarchical(config, vectors, actual_clusters).0
112                    } else {
113                        Self::train_flat(config, vectors, actual_clusters).0
114                    };
115                    let graph = HnswRoutingGraph::build(
116                        actual_clusters,
117                        |left, right| {
118                            let left = left as usize * config.dim;
119                            let right = right as usize * config.dim;
120                            squared_l2(
121                                &leaves[left..left + config.dim],
122                                &leaves[right..right + config.dim],
123                            )
124                        },
125                        config.seed,
126                    );
127                    (leaves, Some(FloatCentroidRouter::Hnsw(graph)))
128                }
129                IvfRoutingMode::Flat | IvfRoutingMode::Auto => {
130                    (Self::train_flat(config, vectors, actual_clusters).0, None)
131                }
132            };
133
134        let version = std::time::SystemTime::now()
135            .duration_since(std::time::UNIX_EPOCH)
136            .unwrap_or_default()
137            .as_nanos() as u64;
138
139        Self {
140            num_clusters: actual_clusters as u32,
141            dim: config.dim,
142            centroids,
143            version,
144            soar_config: config.soar.clone(),
145            routing_index,
146        }
147    }
148
149    fn train_flat(
150        config: &CoarseConfig,
151        vectors: &[Vec<f32>],
152        clusters: usize,
153    ) -> (Vec<f32>, Vec<Vec<usize>>) {
154        let dim = config.dim;
155        let flat: Vec<f32> = vectors.iter().flat_map(|v| v.iter().copied()).collect();
156        let trained = crate::structures::vector::kmeans::train_euclidean_kmeans(
157            &flat,
158            vectors.len(),
159            dim,
160            clusters,
161            config.max_iters,
162            config.seed,
163        );
164        let mut groups = vec![Vec::new(); clusters];
165        for (point, cluster) in trained.assignments.into_iter().enumerate() {
166            groups[cluster].push(point);
167        }
168        (trained.centroids, groups)
169    }
170
171    fn train_hierarchical(
172        config: &CoarseConfig,
173        vectors: &[Vec<f32>],
174        leaf_count: usize,
175    ) -> (Vec<f32>, FloatCentroidRouter) {
176        let parent_count = routing_parent_count(leaf_count).min(vectors.len());
177        let mut parent_config = config.clone();
178        parent_config.routing = IvfRoutingMode::Flat;
179        parent_config.num_clusters = parent_count;
180        let (parent_centroids, groups) = Self::train_flat(&parent_config, vectors, parent_count);
181        let group_sizes: Vec<usize> = groups.iter().map(Vec::len).collect();
182        let child_counts = allocate_child_clusters(&group_sizes, leaf_count);
183        let mut leaves = Vec::with_capacity(leaf_count.saturating_mul(config.dim));
184        let mut children = vec![Vec::new(); parent_count];
185
186        for (parent, (indices, &child_count)) in groups.iter().zip(&child_counts).enumerate() {
187            if child_count == 0 {
188                continue;
189            }
190            let group_vectors: Vec<Vec<f32>> = indices
191                .iter()
192                .map(|&index| vectors[index].clone())
193                .collect();
194            let mut child_config = config.clone();
195            child_config.routing = IvfRoutingMode::Flat;
196            child_config.num_clusters = child_count;
197            child_config.seed = config.seed ^ (parent as u64).wrapping_mul(0x9e37_79b9_7f4a_7c15);
198            let first_leaf = leaves.len() / config.dim;
199            leaves
200                .extend_from_slice(&Self::train_flat(&child_config, &group_vectors, child_count).0);
201            children[parent].extend((first_leaf..first_leaf + child_count).map(|leaf| leaf as u32));
202        }
203        debug_assert_eq!(leaves.len(), leaf_count * config.dim);
204
205        (
206            leaves,
207            FloatCentroidRouter::TwoLevel {
208                parent_centroids,
209                topology: IvfRoutingTopology::from_children(&children),
210            },
211        )
212    }
213
214    /// Find nearest centroid index for a vector (static helper)
215    fn find_nearest_idx_static(vector: &[f32], centroids: &[f32], dim: usize) -> usize {
216        let num_clusters = centroids.len() / dim;
217        let mut best_idx = 0;
218        let mut best_dist = f32::MAX;
219
220        for c in 0..num_clusters {
221            let offset = c * dim;
222            let dist: f32 = vector
223                .iter()
224                .zip(&centroids[offset..offset + dim])
225                .map(|(&a, &b)| (a - b) * (a - b))
226                .sum();
227
228            if dist < best_dist {
229                best_dist = dist;
230                best_idx = c;
231            }
232        }
233
234        best_idx
235    }
236
237    /// Find nearest cluster for a query vector
238    pub fn find_nearest(&self, vector: &[f32]) -> u32 {
239        Self::find_nearest_idx_static(vector, &self.centroids, self.dim) as u32
240    }
241
242    /// Find k nearest clusters for a query vector
243    pub fn find_k_nearest(&self, vector: &[f32], k: usize) -> Vec<u32> {
244        let mut distances: Vec<(u32, f32)> = (0..self.num_clusters)
245            .map(|c| {
246                let offset = c as usize * self.dim;
247                let dist: f32 = vector
248                    .iter()
249                    .zip(&self.centroids[offset..offset + self.dim])
250                    .map(|(&a, &b)| (a - b) * (a - b))
251                    .sum();
252                (c, dist)
253            })
254            .collect();
255
256        // Partial sort: O(n + k log k) instead of O(n log n)
257        if distances.len() > k {
258            distances.select_nth_unstable_by(k, |a, b| a.1.total_cmp(&b.1));
259            distances.truncate(k);
260        }
261        distances.sort_unstable_by(|a, b| a.1.total_cmp(&b.1));
262        distances.into_iter().map(|(c, _)| c).collect()
263    }
264
265    /// Build a versioned probe plan using flat or two-level routing.
266    ///
267    /// The returned leaf IDs are independent of segment contents and can be
268    /// reused across every segment built from this global codebook.
269    pub fn probe(&self, vector: &[f32], k: usize, mode: IvfRoutingMode) -> IvfProbePlan {
270        let take = k.clamp(1, self.num_clusters as usize);
271        let clusters = match effective_routing_mode(mode, self.num_clusters as usize) {
272            IvfRoutingMode::Flat | IvfRoutingMode::Auto => self.find_k_nearest(vector, take),
273            IvfRoutingMode::TwoLevel => self.find_k_nearest_two_level(vector, take),
274            IvfRoutingMode::Hnsw => self.find_k_nearest_hnsw(vector, take),
275        };
276        IvfProbePlan::new(
277            self.version,
278            float_probe_fingerprint(vector, take, mode),
279            clusters,
280        )
281    }
282
283    pub fn validate_routing(&self, mode: IvfRoutingMode) -> Result<(), String> {
284        match effective_routing_mode(mode, self.num_clusters as usize) {
285            IvfRoutingMode::Flat | IvfRoutingMode::Auto => Ok(()),
286            IvfRoutingMode::TwoLevel => {
287                let Some(FloatCentroidRouter::TwoLevel {
288                    parent_centroids,
289                    topology,
290                }) = self.routing_index.as_ref()
291                else {
292                    return Err(
293                        "two-level IVF routing was requested but the global codebook has no matching router"
294                            .to_string(),
295                    );
296                };
297                let parent_count = topology.parent_count();
298                if parent_count == 0
299                    || parent_centroids.len() != parent_count.saturating_mul(self.dim)
300                    || !topology.validate(self.num_clusters as usize)
301                    || parent_centroids.iter().any(|value| !value.is_finite())
302                {
303                    return Err("invalid float two-level IVF routing index".to_string());
304                }
305                Ok(())
306            }
307            IvfRoutingMode::Hnsw => {
308                let Some(FloatCentroidRouter::Hnsw(graph)) = self.routing_index.as_ref() else {
309                    return Err(
310                        "HNSW IVF routing was requested but the global codebook has no HNSW graph"
311                            .to_string(),
312                    );
313                };
314                if !graph.validate(self.num_clusters as usize) {
315                    return Err("invalid float HNSW routing graph".to_string());
316                }
317                Ok(())
318            }
319        }
320    }
321
322    fn find_k_nearest_two_level(&self, vector: &[f32], k: usize) -> Vec<u32> {
323        let Some(FloatCentroidRouter::TwoLevel {
324            parent_centroids,
325            topology,
326        }) = self.routing_index.as_ref()
327        else {
328            return self.find_k_nearest(vector, k);
329        };
330        if topology.parent_count() <= 1 {
331            return self.find_k_nearest(vector, k);
332        }
333
334        let mut parent_scores = vec![0.0; topology.parent_count()];
335        for (parent_id, score) in parent_scores.iter_mut().enumerate() {
336            let offset = parent_id * self.dim;
337            *score = squared_l2(vector, &parent_centroids[offset..offset + self.dim]);
338        }
339        let parent_take =
340            parent_probe_count(k, self.num_clusters as usize, topology.parent_count());
341        let parents = select_best::<false>(&parent_scores, parent_take);
342        let candidate_capacity = parents
343            .iter()
344            .map(|&parent| topology.children(parent as usize).len())
345            .sum();
346        let mut candidates = Vec::with_capacity(candidate_capacity);
347        for parent in parents {
348            for &leaf in topology.children(parent as usize) {
349                candidates.push((leaf, squared_l2(vector, self.get_centroid(leaf))));
350            }
351        }
352        select_best_candidates::<false>(&mut candidates, k)
353    }
354
355    fn find_k_nearest_hnsw(&self, vector: &[f32], k: usize) -> Vec<u32> {
356        let Some(FloatCentroidRouter::Hnsw(graph)) = self.routing_index.as_ref() else {
357            return self.find_k_nearest(vector, k);
358        };
359        graph.search(|leaf| squared_l2(vector, self.get_centroid(leaf)), k)
360    }
361
362    fn find_nearest_hnsw(&self, vector: &[f32]) -> u32 {
363        let Some(FloatCentroidRouter::Hnsw(graph)) = self.routing_index.as_ref() else {
364            return self.find_nearest(vector);
365        };
366        graph.search_one(|leaf| squared_l2(vector, self.get_centroid(leaf)))
367    }
368
369    /// Find k nearest clusters with their distances
370    pub fn find_k_nearest_with_distances(&self, vector: &[f32], k: usize) -> Vec<(u32, f32)> {
371        let mut distances: Vec<(u32, f32)> = (0..self.num_clusters)
372            .map(|c| {
373                let offset = c as usize * self.dim;
374                let dist: f32 = vector
375                    .iter()
376                    .zip(&self.centroids[offset..offset + self.dim])
377                    .map(|(&a, &b)| (a - b) * (a - b))
378                    .sum();
379                (c, dist)
380            })
381            .collect();
382
383        // Partial sort: O(n + k log k) instead of O(n log n)
384        if distances.len() > k {
385            distances.select_nth_unstable_by(k, |a, b| a.1.total_cmp(&b.1));
386            distances.truncate(k);
387        }
388        distances.sort_unstable_by(|a, b| a.1.total_cmp(&b.1));
389        distances
390    }
391
392    /// Assign vector with SOAR (if configured) or standard assignment
393    pub fn assign(&self, vector: &[f32]) -> MultiAssignment {
394        self.assign_with_routing(vector, IvfRoutingMode::Flat)
395    }
396
397    /// Assign during segment construction through the same persisted router
398    /// used at query time. Large codebooks therefore avoid an O(K) scan for
399    /// every indexed vector.
400    pub fn assign_with_routing(&self, vector: &[f32], routing: IvfRoutingMode) -> MultiAssignment {
401        if let Some(ref soar_config) = self.soar_config {
402            self.assign_with_soar_and_routing(vector, soar_config, routing)
403        } else {
404            let primary_cluster = match effective_routing_mode(routing, self.num_clusters as usize)
405            {
406                IvfRoutingMode::Hnsw => self.find_nearest_hnsw(vector),
407                IvfRoutingMode::TwoLevel => self.find_k_nearest_two_level(vector, 1)[0],
408                IvfRoutingMode::Flat | IvfRoutingMode::Auto => self.find_nearest(vector),
409            };
410            MultiAssignment {
411                primary_cluster,
412                secondary_clusters: Vec::new(),
413            }
414        }
415    }
416
417    /// SOAR-style assignment: find secondary clusters with orthogonal residuals
418    pub fn assign_with_soar(&self, vector: &[f32], config: &SoarConfig) -> MultiAssignment {
419        self.assign_with_soar_and_routing(vector, config, IvfRoutingMode::Flat)
420    }
421
422    fn assign_with_soar_and_routing(
423        &self,
424        vector: &[f32],
425        config: &SoarConfig,
426        routing: IvfRoutingMode,
427    ) -> MultiAssignment {
428        let leaf_ids: Vec<u32> = match effective_routing_mode(routing, self.num_clusters as usize) {
429            IvfRoutingMode::TwoLevel => {
430                self.two_level_candidate_leaves(vector, config.num_secondary + 1)
431            }
432            IvfRoutingMode::Hnsw => self.find_k_nearest_hnsw(
433                vector,
434                (config.num_secondary + 1)
435                    .saturating_mul(16)
436                    .max(32)
437                    .min(self.num_clusters as usize),
438            ),
439            IvfRoutingMode::Flat | IvfRoutingMode::Auto => (0..self.num_clusters).collect(),
440        };
441        let primary = leaf_ids
442            .iter()
443            .copied()
444            .min_by(|&left, &right| {
445                squared_l2(vector, self.get_centroid(left))
446                    .total_cmp(&squared_l2(vector, self.get_centroid(right)))
447                    .then_with(|| left.cmp(&right))
448            })
449            .unwrap_or(0);
450        let primary_centroid = self.get_centroid(primary);
451
452        // 2. Compute primary residual r = x - c
453        let residual: Vec<f32> = vector
454            .iter()
455            .zip(primary_centroid)
456            .map(|(v, c)| v - c)
457            .collect();
458
459        let residual_norm_sq: f32 = residual.iter().map(|x| x * x).sum();
460
461        // 3. Check if we should spill (selective spilling)
462        if config.selective && residual_norm_sq < config.spill_threshold * config.spill_threshold {
463            return MultiAssignment {
464                primary_cluster: primary,
465                secondary_clusters: Vec::new(),
466            };
467        }
468
469        // 4. Find secondary clusters that MINIMIZE |⟨r, r'⟩| (orthogonal residuals)
470        let mut candidates: Vec<(u32, f32)> = leaf_ids
471            .into_iter()
472            .filter(|&c| c != primary)
473            .map(|c| {
474                let centroid = self.get_centroid(c);
475                // Compute r' = x - c'
476                // Then compute |⟨r, r'⟩| - we want this SMALL (orthogonal)
477                let dot: f32 = vector
478                    .iter()
479                    .zip(centroid)
480                    .zip(&residual)
481                    .map(|((v, c), r)| (v - c) * r)
482                    .sum();
483                (c, dot.abs())
484            })
485            .collect();
486
487        // Partial sort by orthogonality (smallest dot product first)
488        let take = config.num_secondary.min(candidates.len());
489        if candidates.len() > take {
490            candidates.select_nth_unstable_by(take, |a, b| a.1.total_cmp(&b.1));
491            candidates.truncate(take);
492        }
493
494        MultiAssignment {
495            primary_cluster: primary,
496            secondary_clusters: candidates
497                .iter()
498                .take(config.num_secondary)
499                .map(|(c, _)| *c)
500                .collect(),
501        }
502    }
503
504    fn two_level_candidate_leaves(&self, vector: &[f32], k: usize) -> Vec<u32> {
505        let Some(FloatCentroidRouter::TwoLevel {
506            parent_centroids,
507            topology,
508        }) = self.routing_index.as_ref()
509        else {
510            return (0..self.num_clusters).collect();
511        };
512        let mut parent_scores = vec![0.0; topology.parent_count()];
513        for (parent_id, score) in parent_scores.iter_mut().enumerate() {
514            let offset = parent_id * self.dim;
515            *score = squared_l2(vector, &parent_centroids[offset..offset + self.dim]);
516        }
517        let parents = select_best::<false>(
518            &parent_scores,
519            parent_probe_count(k, self.num_clusters as usize, topology.parent_count()),
520        );
521        let capacity = parents
522            .iter()
523            .map(|&parent| topology.children(parent as usize).len())
524            .sum();
525        let mut leaves = Vec::with_capacity(capacity);
526        for parent in parents {
527            leaves.extend_from_slice(topology.children(parent as usize));
528        }
529        leaves
530    }
531
532    /// Get centroid for a cluster
533    pub fn get_centroid(&self, cluster_id: u32) -> &[f32] {
534        let offset = cluster_id as usize * self.dim;
535        &self.centroids[offset..offset + self.dim]
536    }
537
538    /// Compute residual vector (vector - centroid)
539    pub fn compute_residual(&self, vector: &[f32], cluster_id: u32) -> Vec<f32> {
540        let centroid = self.get_centroid(cluster_id);
541        vector.iter().zip(centroid).map(|(&v, &c)| v - c).collect()
542    }
543
544    /// Memory usage in bytes
545    pub fn size_bytes(&self) -> usize {
546        let routing_bytes = self
547            .routing_index
548            .as_ref()
549            .map_or(0, |router| match router {
550                FloatCentroidRouter::TwoLevel {
551                    parent_centroids,
552                    topology,
553                } => {
554                    parent_centroids.len() * size_of::<f32>()
555                        + topology.parent_count() * size_of::<u32>()
556                        + self.num_clusters as usize * size_of::<u32>()
557                }
558                FloatCentroidRouter::Hnsw(graph) => graph.size_bytes(),
559            });
560        self.centroids.len() * size_of::<f32>() + routing_bytes + 64
561    }
562
563    /// Visit compact routing topology and parent arrays before the potentially
564    /// much larger leaf centroid matrix.
565    #[cfg(feature = "native")]
566    pub(crate) fn visit_routing_regions(&self, visit: &mut dyn FnMut(&'static str, &[u8])) {
567        if let Some(router) = &self.routing_index {
568            match router {
569                FloatCentroidRouter::TwoLevel {
570                    parent_centroids,
571                    topology,
572                } => {
573                    topology.visit_resident_regions(visit);
574                    visit(
575                        "float parent centroids",
576                        super::routing::bytes_of_slice(parent_centroids),
577                    );
578                }
579                FloatCentroidRouter::Hnsw(graph) => graph.visit_resident_regions(visit),
580            }
581        }
582    }
583
584    #[cfg(feature = "native")]
585    pub(crate) fn visit_leaf_centroid_region(&self, visit: &mut dyn FnMut(&'static str, &[u8])) {
586        visit(
587            "float leaf centroids",
588            super::routing::bytes_of_slice(&self.centroids),
589        );
590    }
591
592    /// Encode the current index-level centroid artifact format.
593    pub fn to_bytes(&self) -> std::io::Result<Vec<u8>> {
594        bincode::serde::encode_to_vec(self, bincode::config::standard())
595            .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))
596    }
597}
598
599#[inline]
600fn squared_l2(left: &[f32], right: &[f32]) -> f32 {
601    left.iter()
602        .zip(right)
603        .map(|(&a, &b)| {
604            let delta = a - b;
605            delta * delta
606        })
607        .sum()
608}
609
610#[cfg(test)]
611mod tests {
612    use super::*;
613    use rand::prelude::*;
614
615    #[test]
616    fn test_coarse_centroids_basic() {
617        let dim = 64;
618        let n = 1000;
619        let num_clusters = 16;
620
621        let mut rng = rand::rngs::StdRng::seed_from_u64(42);
622        let vectors: Vec<Vec<f32>> = (0..n)
623            .map(|_| (0..dim).map(|_| rng.random::<f32>() - 0.5).collect())
624            .collect();
625
626        let config = CoarseConfig::new(dim, num_clusters);
627        let centroids = CoarseCentroids::train(&config, &vectors);
628
629        assert_eq!(centroids.num_clusters, num_clusters as u32);
630        assert_eq!(centroids.dim, dim);
631    }
632
633    #[test]
634    fn test_find_nearest() {
635        let dim = 32;
636        let n = 500;
637        let num_clusters = 8;
638
639        let mut rng = rand::rngs::StdRng::seed_from_u64(123);
640        let vectors: Vec<Vec<f32>> = (0..n)
641            .map(|_| (0..dim).map(|_| rng.random::<f32>()).collect())
642            .collect();
643
644        let config = CoarseConfig::new(dim, num_clusters);
645        let centroids = CoarseCentroids::train(&config, &vectors);
646
647        // Test that find_nearest returns valid cluster IDs
648        for v in &vectors {
649            let cluster = centroids.find_nearest(v);
650            assert!(cluster < centroids.num_clusters);
651        }
652    }
653
654    #[test]
655    fn test_soar_assignment() {
656        let dim = 32;
657        let n = 100;
658        let num_clusters = 8;
659
660        let mut rng = rand::rngs::StdRng::seed_from_u64(456);
661        let vectors: Vec<Vec<f32>> = (0..n)
662            .map(|_| (0..dim).map(|_| rng.random::<f32>()).collect())
663            .collect();
664
665        let soar_config = SoarConfig {
666            num_secondary: 2,
667            selective: false,
668            spill_threshold: 0.0,
669        };
670        let config = CoarseConfig::new(dim, num_clusters).with_soar(soar_config);
671        let centroids = CoarseCentroids::train(&config, &vectors);
672
673        // Test SOAR assignment
674        let assignment = centroids.assign(&vectors[0]);
675        assert!(assignment.primary_cluster < centroids.num_clusters);
676        assert_eq!(assignment.secondary_clusters.len(), 2);
677
678        // Secondary clusters should be different from primary
679        for &sec in &assignment.secondary_clusters {
680            assert_ne!(sec, assignment.primary_cluster);
681        }
682    }
683
684    #[test]
685    fn test_serialization() {
686        let dim = 16;
687        let n = 50;
688        let num_clusters = 4;
689
690        let mut rng = rand::rngs::StdRng::seed_from_u64(789);
691        let vectors: Vec<Vec<f32>> = (0..n)
692            .map(|_| (0..dim).map(|_| rng.random::<f32>()).collect())
693            .collect();
694
695        let config = CoarseConfig::new(dim, num_clusters);
696        let centroids = CoarseCentroids::train(&config, &vectors);
697
698        // Serialize and deserialize
699        let bytes = bincode::serde::encode_to_vec(&centroids, bincode::config::standard()).unwrap();
700        let (loaded, consumed): (CoarseCentroids, usize) =
701            bincode::serde::decode_from_slice(&bytes, bincode::config::standard()).unwrap();
702        assert_eq!(consumed, bytes.len());
703
704        assert_eq!(loaded.num_clusters, centroids.num_clusters);
705        assert_eq!(loaded.dim, centroids.dim);
706        assert_eq!(loaded.centroids.len(), centroids.centroids.len());
707    }
708
709    #[test]
710    fn persisted_hnsw_and_two_level_routers_are_valid() {
711        let dim = 4;
712        let mut rng = rand::rngs::StdRng::seed_from_u64(991);
713        let vectors: Vec<Vec<f32>> = (0..256)
714            .map(|_| (0..dim).map(|_| rng.random::<f32>()).collect())
715            .collect();
716
717        for routing in [IvfRoutingMode::Hnsw, IvfRoutingMode::TwoLevel] {
718            let trained =
719                CoarseCentroids::train(&CoarseConfig::new(dim, 16).with_routing(routing), &vectors);
720            trained.validate_routing(routing).unwrap();
721            let plan = trained.probe(&vectors[0], 8, routing);
722            assert_eq!(plan.cluster_ids.len(), 8);
723            assert!(
724                plan.cluster_ids
725                    .iter()
726                    .all(|&cluster| cluster < trained.num_clusters)
727            );
728
729            let bytes =
730                bincode::serde::encode_to_vec(&trained, bincode::config::standard()).unwrap();
731            let (loaded, consumed): (CoarseCentroids, usize) =
732                bincode::serde::decode_from_slice(&bytes, bincode::config::standard()).unwrap();
733            assert_eq!(consumed, bytes.len());
734            loaded.validate_routing(routing).unwrap();
735        }
736    }
737}