Skip to main content

hermes_core/structures/vector/ivf/
soar.rs

1//! SOAR: Spilling with Orthogonality-Amplified Residuals
2//!
3//! Implementation of Google's SOAR algorithm for improved IVF recall:
4//! - Assigns vectors to multiple clusters (primary + secondary)
5//! - Secondary clusters chosen to have orthogonal residuals
6//! - When query is parallel to primary residual (high error), secondary has low error
7//!
8//! Reference: "SOAR: New algorithms for even faster vector search with ScaNN"
9//! <https://research.google/blog/soar-new-algorithms-for-even-faster-vector-search-with-scann/>
10
11use serde::{Deserialize, Serialize};
12
13const DEFAULT_SELECTIVE_SPILL_FRACTION: f32 = 0.30;
14
15/// Configuration for SOAR (Spilling with Orthogonality-Amplified Residuals)
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct SoarConfig {
18    /// Number of secondary cluster assignments. Current trained generations
19    /// use the published two-assignment objective, so builders clamp this to
20    /// one secondary.
21    pub num_secondary: usize,
22    /// Use selective spilling (only spill vectors near cluster boundaries)
23    pub selective: bool,
24    /// Positive values are calibrated residual-norm thresholds. A negative
25    /// value requests build-time calibration to the corresponding spill
26    /// fraction; trained artifacts always persist a positive threshold. This
27    /// tagged representation preserves the serialized structure layout.
28    pub spill_threshold: f32,
29}
30
31impl Default for SoarConfig {
32    fn default() -> Self {
33        Self {
34            num_secondary: 1,
35            selective: true,
36            spill_threshold: -DEFAULT_SELECTIVE_SPILL_FRACTION,
37        }
38    }
39}
40
41impl SoarConfig {
42    /// Create SOAR config with 1 secondary assignment
43    pub fn new() -> Self {
44        Self::default()
45    }
46
47    /// Create SOAR config with specified number of secondary assignments
48    pub fn with_secondary(num_secondary: usize) -> Self {
49        Self {
50            num_secondary: num_secondary.min(1),
51            ..Default::default()
52        }
53    }
54
55    /// Enable/disable selective spilling
56    pub fn selective(mut self, enabled: bool) -> Self {
57        self.selective = enabled;
58        self
59    }
60
61    /// Set spill threshold for selective spilling
62    pub fn threshold(mut self, threshold: f32) -> Self {
63        self.spill_threshold = threshold.max(0.0);
64        self
65    }
66
67    /// Calibrate selective spilling during training to at most a target
68    /// fraction of vectors receiving one secondary assignment.
69    pub fn target_spill_fraction(mut self, fraction: f32) -> Self {
70        self.selective = true;
71        self.spill_threshold = -fraction.clamp(0.0, 1.0);
72        self
73    }
74
75    /// Full spilling (no selectivity) - assigns all vectors to secondary clusters
76    pub fn full() -> Self {
77        Self {
78            num_secondary: 1,
79            selective: false,
80            spill_threshold: 0.0,
81        }
82    }
83
84    /// Compatibility alias for full one-secondary spilling. The generalized
85    /// multi-secondary objective is intentionally not exposed until it is
86    /// implemented and validated.
87    pub fn aggressive() -> Self {
88        Self {
89            num_secondary: 1,
90            selective: false,
91            spill_threshold: 0.0,
92        }
93    }
94
95    pub(crate) fn calibration_target(&self) -> Option<f32> {
96        (self.selective && self.spill_threshold.is_sign_negative())
97            .then(|| (-self.spill_threshold).clamp(0.0, 1.0))
98    }
99}
100
101/// Multi-cluster assignment result from SOAR
102#[derive(Debug, Clone)]
103pub struct MultiAssignment {
104    /// Primary cluster (nearest centroid)
105    pub primary_cluster: u32,
106    /// Secondary clusters (orthogonal residuals)
107    pub secondary_clusters: Vec<u32>,
108}
109
110impl MultiAssignment {
111    /// Create assignment with only primary cluster
112    pub fn primary_only(cluster: u32) -> Self {
113        Self {
114            primary_cluster: cluster,
115            secondary_clusters: Vec::new(),
116        }
117    }
118
119    /// Get all clusters (primary + secondary)
120    pub fn all_clusters(&self) -> impl Iterator<Item = u32> + '_ {
121        std::iter::once(self.primary_cluster).chain(self.secondary_clusters.iter().copied())
122    }
123
124    /// Total number of cluster assignments
125    pub fn num_assignments(&self) -> usize {
126        1 + self.secondary_clusters.len()
127    }
128
129    /// Check if this is a spilled assignment (has secondary clusters)
130    pub fn is_spilled(&self) -> bool {
131        !self.secondary_clusters.is_empty()
132    }
133}
134
135/// Statistics for SOAR assignments
136#[allow(dead_code)]
137#[derive(Debug, Clone, Default)]
138pub struct SoarStats {
139    /// Total vectors assigned
140    pub total_vectors: usize,
141    /// Vectors with secondary assignments (spilled)
142    pub spilled_vectors: usize,
143    /// Total cluster assignments (including secondary)
144    pub total_assignments: usize,
145}
146
147#[allow(dead_code)]
148impl SoarStats {
149    pub fn new() -> Self {
150        Self::default()
151    }
152
153    /// Record an assignment
154    pub fn record(&mut self, assignment: &MultiAssignment) {
155        self.total_vectors += 1;
156        self.total_assignments += assignment.num_assignments();
157        if assignment.is_spilled() {
158            self.spilled_vectors += 1;
159        }
160    }
161
162    /// Spill ratio (fraction of vectors with secondary assignments)
163    pub fn spill_ratio(&self) -> f32 {
164        if self.total_vectors == 0 {
165            0.0
166        } else {
167            self.spilled_vectors as f32 / self.total_vectors as f32
168        }
169    }
170
171    /// Average assignments per vector
172    pub fn avg_assignments(&self) -> f32 {
173        if self.total_vectors == 0 {
174            0.0
175        } else {
176            self.total_assignments as f32 / self.total_vectors as f32
177        }
178    }
179
180    /// Storage overhead factor (1.0 = no overhead, 2.0 = 2x storage)
181    pub fn storage_factor(&self) -> f32 {
182        self.avg_assignments()
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    #[test]
191    fn test_soar_config_default() {
192        let config = SoarConfig::default();
193        assert_eq!(config.num_secondary, 1);
194        assert!(config.selective);
195        assert_eq!(config.calibration_target(), Some(0.30));
196    }
197
198    #[test]
199    fn explicit_threshold_and_target_budget_have_distinct_tags() {
200        let threshold = SoarConfig::new().threshold(0.42);
201        assert_eq!(threshold.calibration_target(), None);
202        assert_eq!(threshold.spill_threshold, 0.42);
203
204        let target = SoarConfig::new().target_spill_fraction(0.25);
205        assert_eq!(target.calibration_target(), Some(0.25));
206    }
207
208    #[test]
209    fn test_multi_assignment() {
210        let assignment = MultiAssignment {
211            primary_cluster: 5,
212            secondary_clusters: vec![2, 7],
213        };
214
215        assert_eq!(assignment.num_assignments(), 3);
216        assert!(assignment.is_spilled());
217
218        let all: Vec<u32> = assignment.all_clusters().collect();
219        assert_eq!(all, vec![5, 2, 7]);
220    }
221
222    #[test]
223    fn test_soar_stats() {
224        let mut stats = SoarStats::new();
225
226        // Primary only assignment
227        stats.record(&MultiAssignment::primary_only(0));
228
229        // Spilled assignment
230        stats.record(&MultiAssignment {
231            primary_cluster: 1,
232            secondary_clusters: vec![2],
233        });
234
235        assert_eq!(stats.total_vectors, 2);
236        assert_eq!(stats.spilled_vectors, 1);
237        assert_eq!(stats.total_assignments, 3);
238        assert_eq!(stats.spill_ratio(), 0.5);
239        assert_eq!(stats.avg_assignments(), 1.5);
240    }
241}