Skip to main content

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}