muvera_rs/encoder/fde_encoder.rs
1use ndarray::{parallel::prelude::*, Array1, Array2, ArrayView2, ArrayView3, Axis};
2use rand::{self, SeedableRng};
3use rand_distr::{Distribution, StandardNormal};
4
5use crate::types::{Aggregation, FDEFloat};
6
7/// Trait for fixed-dimensional encoding from token embeddings.
8///
9/// This trait defines methods for encoding token embeddings into
10/// fixed-dimensional vectors using the FDE (Fixed Dimensional Encoding) algorithm.
11///
12/// # Type Parameters
13/// - `T`: The numeric float type, e.g., `f32` or `f64`.
14pub trait FDEEncoding<T: FDEFloat + Send + Sync> {
15 /// Encode a single multi-vector (2D tokens) into a fixed-dimensional vector.
16 ///
17 /// # Arguments
18 /// - `tokens`: 2D array of shape `(num_tokens, embedding_dim)`.
19 /// - `mode`: Aggregation mode, either sum or average across buckets.
20 ///
21 /// # Returns
22 /// - 1D array of length `buckets * embedding_dim` representing the encoded vector.
23 fn encode(&self, tokens: ArrayView2<T>, mode: Aggregation) -> Array1<T>;
24
25 /// Encode a batch of multi-vectors (3D tokens) into fixed-dimensional vectors.
26 ///
27 /// # Arguments
28 /// - `batch_tokens`: 3D array of shape `(batch_size, num_tokens, embedding_dim)`.
29 /// - `mode`: Aggregation mode, either sum or average across buckets.
30 ///
31 /// # Returns
32 /// - 2D array of shape `(batch_size, buckets * embedding_dim)` where each row
33 /// is the encoded vector for the corresponding batch element.
34 fn batch_encode(&self, batch_tokens: ArrayView3<T>, mode: Aggregation) -> Array2<T>;
35
36 /// Encode a query token embedding using sum aggregation.
37 ///
38 /// # Arguments
39 /// - `tokens`: 2D array of shape `(num_tokens, embedding_dim)`.
40 ///
41 /// # Returns
42 /// - Fixed-dimensional encoded query vector.
43 fn encode_query(&self, tokens: ArrayView2<T>) -> Array1<T> {
44 self.encode(tokens, Aggregation::Sum)
45 }
46
47 /// Encode a document token embedding using average aggregation.
48 ///
49 /// # Arguments
50 /// - `tokens`: 2D array of shape `(num_tokens, embedding_dim)`.
51 ///
52 /// # Returns
53 /// - Fixed-dimensional encoded document vector.
54 fn encode_doc(&self, tokens: ArrayView2<T>) -> Array1<T> {
55 self.encode(tokens, Aggregation::Avg)
56 }
57
58 /// Batch encode queries using sum aggregation.
59 ///
60 /// # Arguments
61 /// - `batch_tokens`: 3D array of shape `(batch_size, num_tokens, embedding_dim)`.
62 ///
63 /// # Returns
64 /// - 2D array of encoded query vectors.
65 fn encode_query_batch(&self, batch_tokens: ArrayView3<T>) -> Array2<T> {
66 self.batch_encode(batch_tokens, Aggregation::Sum)
67 }
68
69 /// Batch encode documents using average aggregation.
70 ///
71 /// # Arguments
72 /// - `batch_tokens`: 3D array of shape `(batch_size, num_tokens, embedding_dim)`.
73 ///
74 /// # Returns
75 /// - 2D array of encoded document vectors.
76 fn encode_doc_batch(&self, batch_tokens: ArrayView3<T>) -> Array2<T> {
77 self.batch_encode(batch_tokens, Aggregation::Avg)
78 }
79}
80/// Fixed Dimensional Encoder (FDE) implementation.
81///
82/// Encodes variable-length token embeddings into fixed-length vectors using
83/// randomized hyperplanes and aggregation.
84///
85/// # Fields
86/// - `buckets`: Number of hyperplanes / buckets to hash tokens into.
87/// - `dim`: Embedding dimensionality of input tokens.
88/// - `hyperplanes`: Random hyperplanes matrix used for projection and hashing.
89pub struct FDEEncoder<T: FDEFloat> {
90 pub buckets: usize,
91 pub dim: usize,
92 pub hyperplanes: Array2<T>,
93}
94
95impl<T: FDEFloat> FDEEncoder<T> {
96 /// Creates a new FDE encoder with the specified number of buckets and embedding dimension.
97 ///
98 /// # Arguments
99 /// - `buckets`: Number of hash buckets (hyperplanes).
100 /// - `dim`: Dimensionality of token embeddings.
101 /// - `seed`: RNG seed for reproducible hyperplane initialization.
102 ///
103 /// # Returns
104 /// A new `FDEEncoder` instance.
105 pub fn new(buckets: usize, dim: usize, seed: u64) -> Self {
106 let mut rng = rand::rngs::StdRng::seed_from_u64(seed);
107
108 // Create hyperplanes directly with the right shape
109 let hyperplanes = Array2::from_shape_fn((dim, buckets), |_| {
110 let sample: f64 = StandardNormal.sample(&mut rng);
111 T::from(sample).unwrap() // safe unwrap since f32/f64 supported
112 });
113
114 Self {
115 buckets,
116 dim,
117 hyperplanes,
118 }
119 }
120}
121
122impl Default for FDEEncoder<f32> {
123 fn default() -> Self {
124 Self::new(128, 768, 42)
125 }
126}
127
128impl<T: FDEFloat + Send + Sync + num_traits::FromPrimitive> FDEEncoding<T> for FDEEncoder<T> {
129 /// Encode a single multi-vector of tokens into a fixed-dimensional vector.
130 ///
131 /// Projects tokens onto hyperplanes, hashes them into buckets,
132 /// aggregates by sum or average per bucket, and concatenates the results.
133 ///
134 /// # Arguments
135 /// - `multi_vector_tokens`: 2D array of token embeddings `(num_tokens, dim)`.
136 /// - `mode`: Aggregation mode (`Sum` or `Avg`).
137 ///
138 /// # Returns
139 /// A 1D array of length `buckets * dim` representing the encoded vector.
140
141 fn encode(&self, multi_vector_tokens: ArrayView2<T>, mode: Aggregation) -> Array1<T> {
142 let buckets = self.buckets;
143 assert_eq!(multi_vector_tokens.ncols(), self.dim);
144
145 // 1. Projection (num_tokens, buckets)
146 let projections = multi_vector_tokens.dot(&self.hyperplanes);
147
148 // 2. ReLU Activation
149 let activated = projections.mapv(|x| if x > T::zero() { x } else { T::zero() });
150
151 // 3. Aggregation
152 match mode {
153 // Query: Max-pooling
154 Aggregation::Sum => {
155 activated.fold_axis(Axis(0), T::zero(), |&acc, &x| if x > acc { x } else { acc })
156 }
157 // Document: Mean-pooling
158 Aggregation::Avg => {
159 activated.mean_axis(Axis(0)).unwrap_or_else(|| Array1::zeros(buckets))
160 }
161 }
162 }
163
164 /// Encode a batch of multi-vectors using parallel processing.
165 ///
166 /// Divides the batch across threads for concurrent encoding.
167 ///
168 /// # Arguments
169 /// - `batch_tokens`: 3D array `(batch_size, num_tokens, dim)`.
170 /// - `mode`: Aggregation mode (`Sum` or `Avg`).
171 ///
172 /// # Returns
173 /// 2D array of encoded vectors `(batch_size, buckets * dim)`.
174
175 fn batch_encode(&self, batch_tokens: ArrayView3<T>, mode: Aggregation) -> Array2<T>
176 where
177 T: FDEFloat + Sync + Send,
178 Self: Sync,
179 {
180 let (batch_size, _n, _) = batch_tokens.dim();
181 let buckets = self.buckets;
182
183 // Pre-allocate output array
184 let mut result = Array2::<T>::zeros((batch_size, buckets));
185
186 // Process in parallel chunks for better cache locality
187 let chunk_size =
188 (batch_size + rayon::current_num_threads() - 1) / rayon::current_num_threads();
189
190 result
191 .axis_chunks_iter_mut(Axis(0), chunk_size)
192 .into_par_iter()
193 .zip(batch_tokens.axis_chunks_iter(Axis(0), chunk_size))
194 .for_each(|(mut result_chunk, tokens_chunk)| {
195 for (i, tokens_2d) in tokens_chunk.axis_iter(Axis(0)).enumerate() {
196 let encoded = self.encode(tokens_2d, mode);
197 result_chunk.row_mut(i).assign(&encoded);
198 }
199 });
200
201 result
202 }
203}
204
205#[cfg(test)]
206mod tests {
207 use ndarray::array;
208
209 use super::*;
210
211 const DIM: usize = 4;
212 const BUCKETS: usize = 3;
213
214 fn create_encoder() -> FDEEncoder<f32> {
215 FDEEncoder::new(BUCKETS, DIM, 42)
216 }
217
218 #[test]
219 fn test_output_dimension_reduction() {
220 let enc = create_encoder();
221 let tokens = array![[0.1, 0.2, 0.3, 0.4], [0.5, 0.6, 0.7, 0.8]];
222 let vec = enc.encode(tokens.view(), Aggregation::Sum);
223
224 // Output length must be equal to buckets, not buckets * dim
225 assert_eq!(vec.len(), BUCKETS);
226 }
227
228 #[test]
229 fn test_relu_behavior_correctness() {
230 let enc = create_encoder();
231 let tokens = array![[1.0, 1.0, 1.0, 1.0]];
232 let projections = tokens.dot(&enc.hyperplanes);
233 let encoded = enc.encode(tokens.view(), Aggregation::Sum);
234
235 for i in 0..BUCKETS {
236 // if the projection is negative or zero, the encoded value should be zero due to ReLU
237 if projections[[0, i]] <= 0.0 {
238 assert_eq!(encoded[i], 0.0);
239 } else {
240 assert_eq!(encoded[i], projections[[0, i]]);
241 }
242 }
243 }
244
245 #[test]
246 fn test_asymmetric_property() {
247 let enc = create_encoder();
248 let tokens = array![[1.0, 1.0, 1.0, 1.0], [0.1, 0.1, 0.1, 0.1]];
249
250 let query_vec = enc.encode_query(tokens.view()); // Max
251 let doc_vec = enc.encode_doc(tokens.view()); // Mean
252
253 // Max aggregation should produce values >= Mean aggregation
254 for i in 0..BUCKETS {
255 assert!(query_vec[i] >= doc_vec[i]);
256 }
257 }
258
259 #[test]
260 fn test_batch_encode_concurrency() {
261 let enc = create_encoder();
262 let batch_tokens = array![
263 [[0.1, 0.2, 0.3, 0.4], [0.5, 0.6, 0.7, 0.8]],
264 [[0.9, 0.1, 0.2, 0.3], [0.4, 0.5, 0.6, 0.7]]
265 ];
266 let result = enc.batch_encode(batch_tokens.view(), Aggregation::Avg);
267 assert_eq!(result.shape(), &[2, BUCKETS]);
268 }
269
270 #[test]
271 fn test_numerical_stability() {
272 let enc = create_encoder();
273 let tokens = array![[f32::MAX, f32::MIN, 0.0, 1.0]];
274 let vec = enc.encode(tokens.view(), Aggregation::Avg);
275 assert!(vec.iter().all(|&x| x.is_finite()));
276 }
277
278 #[test]
279 fn test_reproducibility() {
280 let enc1: FDEEncoder<f64> = FDEEncoder::new(BUCKETS, DIM, 42);
281 let enc2 = FDEEncoder::new(BUCKETS, DIM, 42);
282 let tokens = array![[0.1, 0.2, 0.3, 0.4]];
283 assert_eq!(
284 enc1.encode_query(tokens.view()),
285 enc2.encode_query(tokens.view())
286 );
287 }
288
289 #[test]
290 fn test_padding_impact() {
291 let enc = create_encoder();
292 let tokens_real = array![[0.5, 0.5, 0.5, 0.5]];
293 let tokens_with_padding = array![[0.5, 0.5, 0.5, 0.5], [0.0, 0.0, 0.0, 0.0]];
294
295 // Sum/Max should ignore zero padding, Mean will be affected.
296 assert_eq!(
297 enc.encode_query(tokens_real.view()),
298 enc.encode_query(tokens_with_padding.view())
299 );
300 }
301}