Skip to main content

ipfrs_semantic/
embedding_aggregator.rs

1//! Embedding aggregation strategies for combining multiple vector representations.
2//!
3//! This module provides [`EmbeddingAggregator`], which merges several embedding
4//! vectors into one using configurable pooling strategies: mean, weighted mean,
5//! max, min, sum, geometric mean, and attention-based pooling.
6//!
7//! # Example
8//!
9//! ```rust
10//! use ipfrs_semantic::embedding_aggregator::{
11//!     AggregationInput, AggregationMethod, EmbeddingAggregator,
12//!     EmbeddingAggregatorConfig,
13//! };
14//!
15//! let method = AggregationMethod::Mean;
16//! let config = EmbeddingAggregatorConfig::default();
17//! let mut agg = EmbeddingAggregator::new(method, config);
18//!
19//! let inputs = vec![
20//!     AggregationInput::new("a", vec![1.0, 0.0], 1.0),
21//!     AggregationInput::new("b", vec![0.0, 1.0], 1.0),
22//! ];
23//!
24//! let result = agg.aggregate(&inputs).unwrap();
25//! assert_eq!(result.input_count, 2);
26//! assert_eq!(result.dims, 2);
27//! ```
28
29use std::collections::VecDeque;
30
31// ---------------------------------------------------------------------------
32// Error type
33// ---------------------------------------------------------------------------
34
35/// Errors returned by [`EmbeddingAggregator`] operations.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum AggregatorError {
38    /// No inputs were provided.
39    EmptyInput,
40    /// All embeddings must have the same dimensionality.
41    DimensionMismatch {
42        /// Expected dimensionality.
43        expected: usize,
44        /// Actual dimensionality of the offending vector.
45        got: usize,
46    },
47    /// The number of explicit weights does not match the number of inputs.
48    WeightCountMismatch {
49        /// Expected number of weights.
50        expected: usize,
51        /// Actual number of weights supplied.
52        got: usize,
53    },
54    /// The attention query vector is invalid (e.g. wrong length or all-zero).
55    InvalidQuery,
56}
57
58impl std::fmt::Display for AggregatorError {
59    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        match self {
61            Self::EmptyInput => write!(f, "no input embeddings were provided"),
62            Self::DimensionMismatch { expected, got } => {
63                write!(f, "dimension mismatch: expected {expected}, got {got}")
64            }
65            Self::WeightCountMismatch { expected, got } => write!(
66                f,
67                "weight count mismatch: expected {expected} weights, got {got}"
68            ),
69            Self::InvalidQuery => {
70                write!(f, "attention query vector is invalid or has wrong length")
71            }
72        }
73    }
74}
75
76impl std::error::Error for AggregatorError {}
77
78// ---------------------------------------------------------------------------
79// Input
80// ---------------------------------------------------------------------------
81
82/// A single embedding with an optional scalar weight used by [`AggregationMethod::WeightedMean`].
83#[derive(Debug, Clone)]
84pub struct AggregationInput {
85    /// Identifier for this input (e.g. document ID, chunk ID).
86    pub id: String,
87    /// The embedding vector.
88    pub embedding: Vec<f64>,
89    /// Scalar weight.  Used directly by `WeightedMean`; ignored by all other
90    /// methods (all inputs are treated equally).
91    pub weight: f64,
92}
93
94impl AggregationInput {
95    /// Create a new [`AggregationInput`].
96    pub fn new(id: impl Into<String>, embedding: Vec<f64>, weight: f64) -> Self {
97        Self {
98            id: id.into(),
99            embedding,
100            weight,
101        }
102    }
103}
104
105// ---------------------------------------------------------------------------
106// Method
107// ---------------------------------------------------------------------------
108
109/// Strategy used to pool multiple embedding vectors into one.
110#[derive(Debug, Clone)]
111pub enum AggregationMethod {
112    /// Element-wise arithmetic mean.
113    Mean,
114    /// Weighted arithmetic mean.  The `weights` slice is normalised internally
115    /// so that the caller does not need to ensure they sum to 1.
116    WeightedMean {
117        /// Per-input weights (must have the same length as the input slice).
118        weights: Vec<f64>,
119    },
120    /// Element-wise maximum.
121    Max,
122    /// Element-wise minimum.
123    Min,
124    /// Element-wise sum.
125    Sum,
126    /// Geometric mean: `exp(mean(ln(|v[j]| + ε))) * sign(mean(v[j]))`.
127    GeometricMean,
128    /// Attention pooling: softmax(query · embeddings / sqrt(d)) weighted sum.
129    AttentionPooling {
130        /// Query vector used to compute attention scores.
131        query: Vec<f64>,
132    },
133}
134
135impl AggregationMethod {
136    /// Human-readable name of the method.
137    pub fn name(&self) -> &'static str {
138        match self {
139            Self::Mean => "Mean",
140            Self::WeightedMean { .. } => "WeightedMean",
141            Self::Max => "Max",
142            Self::Min => "Min",
143            Self::Sum => "Sum",
144            Self::GeometricMean => "GeometricMean",
145            Self::AttentionPooling { .. } => "AttentionPooling",
146        }
147    }
148}
149
150// ---------------------------------------------------------------------------
151// Result
152// ---------------------------------------------------------------------------
153
154/// The outcome of a single aggregation operation.
155#[derive(Debug, Clone)]
156pub struct AggregationResult {
157    /// Name of the aggregation method used.
158    pub method: String,
159    /// The aggregated output vector.
160    pub output: Vec<f64>,
161    /// Number of inputs that were aggregated.
162    pub input_count: usize,
163    /// Dimensionality of the output vector.
164    pub dims: usize,
165    /// L2 norm of `output`.
166    pub norm: f64,
167}
168
169// ---------------------------------------------------------------------------
170// Config
171// ---------------------------------------------------------------------------
172
173/// Configuration options for [`EmbeddingAggregator`].
174#[derive(Debug, Clone, Default)]
175pub struct EmbeddingAggregatorConfig {
176    /// If `true`, L2-normalise the output vector before storing it in the result.
177    pub normalize_output: bool,
178    /// If `true`, replace any all-zero input embedding with a uniform vector
179    /// `1/sqrt(d)` before aggregation.
180    pub handle_zeros: bool,
181}
182
183// ---------------------------------------------------------------------------
184// Stats
185// ---------------------------------------------------------------------------
186
187/// Aggregate statistics derived from the aggregator's history.
188#[derive(Debug, Clone)]
189pub struct EaAggregatorStats {
190    /// Total number of aggregation calls recorded in history.
191    pub total_aggregations: u64,
192    /// Average number of inputs per aggregation call.
193    pub avg_input_count: f64,
194    /// Average L2 norm of the output vectors.
195    pub avg_output_norm: f64,
196    /// Name of the configured aggregation method.
197    pub method_name: String,
198}
199
200// ---------------------------------------------------------------------------
201// Core aggregator
202// ---------------------------------------------------------------------------
203
204/// Aggregates multiple embedding vectors into a single representation.
205///
206/// Maintains a rolling history of the most recent aggregation results for
207/// monitoring and introspection.
208pub struct EmbeddingAggregator {
209    /// Configuration flags.
210    pub config: EmbeddingAggregatorConfig,
211    /// The pooling method.
212    pub method: AggregationMethod,
213    /// Rolling history of past results.
214    pub history: VecDeque<AggregationResult>,
215    /// Maximum number of entries kept in `history`.
216    pub max_history: usize,
217}
218
219impl EmbeddingAggregator {
220    /// Create a new [`EmbeddingAggregator`] with the given method and config.
221    ///
222    /// The history capacity defaults to 1 000 entries.
223    pub fn new(method: AggregationMethod, config: EmbeddingAggregatorConfig) -> Self {
224        Self {
225            config,
226            method,
227            history: VecDeque::new(),
228            max_history: 1_000,
229        }
230    }
231
232    /// Create a new aggregator with an explicit history capacity.
233    pub fn with_history_capacity(
234        method: AggregationMethod,
235        config: EmbeddingAggregatorConfig,
236        max_history: usize,
237    ) -> Self {
238        Self {
239            config,
240            method,
241            history: VecDeque::with_capacity(max_history.min(1_000_000)),
242            max_history,
243        }
244    }
245
246    // -----------------------------------------------------------------------
247    // Public API
248    // -----------------------------------------------------------------------
249
250    /// Aggregate a slice of [`AggregationInput`] values using `self.method`.
251    ///
252    /// All inputs must share the same dimensionality.  Returns an
253    /// [`AggregationResult`] and records it in the history ring.
254    pub fn aggregate(
255        &mut self,
256        inputs: &[AggregationInput],
257    ) -> Result<AggregationResult, AggregatorError> {
258        if inputs.is_empty() {
259            return Err(AggregatorError::EmptyInput);
260        }
261
262        let dims = inputs[0].embedding.len();
263        for inp in inputs.iter().skip(1) {
264            if inp.embedding.len() != dims {
265                return Err(AggregatorError::DimensionMismatch {
266                    expected: dims,
267                    got: inp.embedding.len(),
268                });
269            }
270        }
271
272        // Optionally replace zero vectors with uniform ones.
273        let embeddings: Vec<Vec<f64>> = if self.config.handle_zeros {
274            inputs
275                .iter()
276                .map(|inp| {
277                    if Self::l2_norm(&inp.embedding) < f64::EPSILON {
278                        vec![1.0 / (dims as f64).sqrt(); dims]
279                    } else {
280                        inp.embedding.clone()
281                    }
282                })
283                .collect()
284        } else {
285            inputs.iter().map(|inp| inp.embedding.clone()).collect()
286        };
287
288        let method_name = self.method.name().to_owned();
289        let output = match &self.method {
290            AggregationMethod::Mean => compute_mean(&embeddings),
291            AggregationMethod::WeightedMean { weights } => {
292                if weights.len() != inputs.len() {
293                    return Err(AggregatorError::WeightCountMismatch {
294                        expected: inputs.len(),
295                        got: weights.len(),
296                    });
297                }
298                compute_weighted_mean(&embeddings, weights)
299            }
300            AggregationMethod::Max => compute_max(&embeddings),
301            AggregationMethod::Min => compute_min(&embeddings),
302            AggregationMethod::Sum => compute_sum(&embeddings),
303            AggregationMethod::GeometricMean => compute_geometric_mean(&embeddings),
304            AggregationMethod::AttentionPooling { query } => {
305                if query.len() != dims {
306                    return Err(AggregatorError::InvalidQuery);
307                }
308                let scores = attention_scores_impl(query, &embeddings);
309                compute_weighted_mean_with_weights(&embeddings, &scores)
310            }
311        };
312
313        // Use per-input weights for WeightedMean if method weight count matches,
314        // otherwise fall back to the method above.  (Already handled above via
315        // the WeightedMean arm.)
316        // NOTE: the WeightedMean arm with inputs[i].weight is an alternative
317        // pathway when caller wants dynamic per-call weights embedded in the
318        // AggregationInput structs instead of the method enum.
319        // That is exposed via aggregate_with_input_weights below.
320
321        let output = if self.config.normalize_output {
322            Self::l2_normalize(&output)
323        } else {
324            output
325        };
326
327        let norm = Self::l2_norm(&output);
328        let result = AggregationResult {
329            method: method_name,
330            output,
331            input_count: inputs.len(),
332            dims,
333            norm,
334        };
335
336        self.push_history(result.clone());
337        Ok(result)
338    }
339
340    /// Convenience method that treats all `embeddings` with weight `1.0` and
341    /// applies `self.method`.
342    pub fn aggregate_raw(
343        &mut self,
344        embeddings: &[Vec<f64>],
345    ) -> Result<AggregationResult, AggregatorError> {
346        let inputs: Vec<AggregationInput> = embeddings
347            .iter()
348            .enumerate()
349            .map(|(i, e)| AggregationInput::new(format!("raw_{i}"), e.clone(), 1.0))
350            .collect();
351        self.aggregate(&inputs)
352    }
353
354    /// Aggregate using the `weight` field of each [`AggregationInput`] directly,
355    /// ignoring the method's own weight vector.  Useful when weights are
356    /// determined dynamically (e.g. relevance scores).
357    pub fn aggregate_with_input_weights(
358        &mut self,
359        inputs: &[AggregationInput],
360    ) -> Result<AggregationResult, AggregatorError> {
361        if inputs.is_empty() {
362            return Err(AggregatorError::EmptyInput);
363        }
364        let dims = inputs[0].embedding.len();
365        for inp in inputs.iter().skip(1) {
366            if inp.embedding.len() != dims {
367                return Err(AggregatorError::DimensionMismatch {
368                    expected: dims,
369                    got: inp.embedding.len(),
370                });
371            }
372        }
373        let embeddings: Vec<Vec<f64>> = inputs.iter().map(|i| i.embedding.clone()).collect();
374        let weights: Vec<f64> = inputs.iter().map(|i| i.weight).collect();
375        let output = compute_weighted_mean(&embeddings, &weights);
376        let output = if self.config.normalize_output {
377            Self::l2_normalize(&output)
378        } else {
379            output
380        };
381        let norm = Self::l2_norm(&output);
382        let result = AggregationResult {
383            method: "WeightedMeanInputWeights".to_owned(),
384            output,
385            input_count: inputs.len(),
386            dims,
387            norm,
388        };
389        self.push_history(result.clone());
390        Ok(result)
391    }
392
393    /// Aggregate the output vectors from several [`AggregationResult`]s using
394    /// the provided `method`.
395    pub fn merge_results(
396        results: &[AggregationResult],
397        method: AggregationMethod,
398    ) -> Result<AggregationResult, AggregatorError> {
399        if results.is_empty() {
400            return Err(AggregatorError::EmptyInput);
401        }
402        let dims = results[0].dims;
403        for r in results.iter().skip(1) {
404            if r.dims != dims {
405                return Err(AggregatorError::DimensionMismatch {
406                    expected: dims,
407                    got: r.dims,
408                });
409            }
410        }
411        let embeddings: Vec<Vec<f64>> = results.iter().map(|r| r.output.clone()).collect();
412        let method_name = method.name().to_owned();
413        let output = match &method {
414            AggregationMethod::Mean => compute_mean(&embeddings),
415            AggregationMethod::WeightedMean { weights } => {
416                if weights.len() != results.len() {
417                    return Err(AggregatorError::WeightCountMismatch {
418                        expected: results.len(),
419                        got: weights.len(),
420                    });
421                }
422                compute_weighted_mean(&embeddings, weights)
423            }
424            AggregationMethod::Max => compute_max(&embeddings),
425            AggregationMethod::Min => compute_min(&embeddings),
426            AggregationMethod::Sum => compute_sum(&embeddings),
427            AggregationMethod::GeometricMean => compute_geometric_mean(&embeddings),
428            AggregationMethod::AttentionPooling { query } => {
429                if query.len() != dims {
430                    return Err(AggregatorError::InvalidQuery);
431                }
432                let scores = attention_scores_impl(query, &embeddings);
433                compute_weighted_mean_with_weights(&embeddings, &scores)
434            }
435        };
436        let norm = Self::l2_norm(&output);
437        Ok(AggregationResult {
438            method: method_name,
439            output,
440            input_count: results.len(),
441            dims,
442            norm,
443        })
444    }
445
446    // -----------------------------------------------------------------------
447    // Utilities
448    // -----------------------------------------------------------------------
449
450    /// L2-normalise `v` in place.  Returns `v` unchanged if its norm is ≈ 0.
451    pub fn l2_normalize(v: &[f64]) -> Vec<f64> {
452        let n = Self::l2_norm(v);
453        if n < f64::EPSILON {
454            return v.to_vec();
455        }
456        v.iter().map(|x| x / n).collect()
457    }
458
459    /// Compute the L2 (Euclidean) norm of `v`.
460    pub fn l2_norm(v: &[f64]) -> f64 {
461        v.iter().map(|x| x * x).sum::<f64>().sqrt()
462    }
463
464    /// Cosine similarity between two vectors.
465    ///
466    /// Returns `0.0` if either vector is the zero vector.
467    pub fn cosine_similarity(a: &[f64], b: &[f64]) -> f64 {
468        let na = Self::l2_norm(a);
469        let nb = Self::l2_norm(b);
470        if na < f64::EPSILON || nb < f64::EPSILON {
471            return 0.0;
472        }
473        let dot: f64 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
474        (dot / (na * nb)).clamp(-1.0, 1.0)
475    }
476
477    /// Compute softmax attention scores: `softmax(query · embedding[i] / sqrt(d))`.
478    pub fn attention_scores(query: &[f64], embeddings: &[Vec<f64>]) -> Vec<f64> {
479        attention_scores_impl(query, embeddings)
480    }
481
482    // -----------------------------------------------------------------------
483    // History
484    // -----------------------------------------------------------------------
485
486    /// Return the `n` most-recent [`AggregationResult`]s (newest last).
487    pub fn recent_history(&self, n: usize) -> Vec<&AggregationResult> {
488        let skip = self.history.len().saturating_sub(n);
489        self.history.iter().skip(skip).collect()
490    }
491
492    /// Compute statistics over the full history.
493    pub fn stats(&self) -> EaAggregatorStats {
494        let total = self.history.len() as u64;
495        let avg_input_count = if self.history.is_empty() {
496            0.0
497        } else {
498            self.history
499                .iter()
500                .map(|r| r.input_count as f64)
501                .sum::<f64>()
502                / total as f64
503        };
504        let avg_output_norm = if self.history.is_empty() {
505            0.0
506        } else {
507            self.history.iter().map(|r| r.norm).sum::<f64>() / total as f64
508        };
509        EaAggregatorStats {
510            total_aggregations: total,
511            avg_input_count,
512            avg_output_norm,
513            method_name: self.method.name().to_owned(),
514        }
515    }
516
517    // -----------------------------------------------------------------------
518    // Private helpers
519    // -----------------------------------------------------------------------
520
521    fn push_history(&mut self, result: AggregationResult) {
522        if self.history.len() >= self.max_history && self.max_history > 0 {
523            self.history.pop_front();
524        }
525        if self.max_history > 0 {
526            self.history.push_back(result);
527        }
528    }
529}
530
531// ---------------------------------------------------------------------------
532// Free-function compute kernels
533// ---------------------------------------------------------------------------
534
535fn compute_mean(embeddings: &[Vec<f64>]) -> Vec<f64> {
536    let n = embeddings.len() as f64;
537    let dims = embeddings[0].len();
538    let mut out = vec![0.0_f64; dims];
539    for emb in embeddings {
540        for (j, v) in emb.iter().enumerate() {
541            out[j] += v;
542        }
543    }
544    out.iter_mut().for_each(|x| *x /= n);
545    out
546}
547
548fn compute_weighted_mean(embeddings: &[Vec<f64>], weights: &[f64]) -> Vec<f64> {
549    let total: f64 = weights.iter().sum();
550    let denom = if total.abs() < f64::EPSILON {
551        1.0
552    } else {
553        total
554    };
555    let dims = embeddings[0].len();
556    let mut out = vec![0.0_f64; dims];
557    for (emb, &w) in embeddings.iter().zip(weights.iter()) {
558        for (j, v) in emb.iter().enumerate() {
559            out[j] += w * v;
560        }
561    }
562    out.iter_mut().for_each(|x| *x /= denom);
563    out
564}
565
566fn compute_weighted_mean_with_weights(embeddings: &[Vec<f64>], weights: &[f64]) -> Vec<f64> {
567    compute_weighted_mean(embeddings, weights)
568}
569
570fn compute_max(embeddings: &[Vec<f64>]) -> Vec<f64> {
571    let dims = embeddings[0].len();
572    let mut out = vec![f64::NEG_INFINITY; dims];
573    for emb in embeddings {
574        for (j, &v) in emb.iter().enumerate() {
575            if v > out[j] {
576                out[j] = v;
577            }
578        }
579    }
580    out
581}
582
583fn compute_min(embeddings: &[Vec<f64>]) -> Vec<f64> {
584    let dims = embeddings[0].len();
585    let mut out = vec![f64::INFINITY; dims];
586    for emb in embeddings {
587        for (j, &v) in emb.iter().enumerate() {
588            if v < out[j] {
589                out[j] = v;
590            }
591        }
592    }
593    out
594}
595
596fn compute_sum(embeddings: &[Vec<f64>]) -> Vec<f64> {
597    let dims = embeddings[0].len();
598    let mut out = vec![0.0_f64; dims];
599    for emb in embeddings {
600        for (j, &v) in emb.iter().enumerate() {
601            out[j] += v;
602        }
603    }
604    out
605}
606
607/// Geometric mean: `exp(mean(ln(|v[j]| + ε)))` with sign of arithmetic mean.
608fn compute_geometric_mean(embeddings: &[Vec<f64>]) -> Vec<f64> {
609    const EPS: f64 = 1e-10;
610    let n = embeddings.len() as f64;
611    let dims = embeddings[0].len();
612    let mut out = vec![0.0_f64; dims];
613
614    for j in 0..dims {
615        // Arithmetic mean sign
616        let arith_mean: f64 = embeddings.iter().map(|e| e[j]).sum::<f64>() / n;
617        let sign = if arith_mean >= 0.0 { 1.0_f64 } else { -1.0_f64 };
618
619        // Geometric mean of absolute values (+ε for numerical stability)
620        let log_mean: f64 = embeddings
621            .iter()
622            .map(|e| (e[j].abs() + EPS).ln())
623            .sum::<f64>()
624            / n;
625        out[j] = log_mean.exp() * sign;
626    }
627    out
628}
629
630/// Softmax of scaled dot products.
631fn attention_scores_impl(query: &[f64], embeddings: &[Vec<f64>]) -> Vec<f64> {
632    let dims = query.len() as f64;
633    let scale = dims.sqrt();
634
635    let raw: Vec<f64> = embeddings
636        .iter()
637        .map(|emb| {
638            let dot: f64 = query.iter().zip(emb.iter()).map(|(q, e)| q * e).sum();
639            dot / scale
640        })
641        .collect();
642
643    softmax(&raw)
644}
645
646fn softmax(logits: &[f64]) -> Vec<f64> {
647    if logits.is_empty() {
648        return Vec::new();
649    }
650    let max_val = logits.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
651    let exps: Vec<f64> = logits.iter().map(|&x| (x - max_val).exp()).collect();
652    let sum: f64 = exps.iter().sum();
653    let denom = if sum < f64::EPSILON { 1.0 } else { sum };
654    exps.iter().map(|e| e / denom).collect()
655}
656
657// ---------------------------------------------------------------------------
658// Tests
659// ---------------------------------------------------------------------------
660
661#[cfg(test)]
662mod tests {
663    use super::{
664        AggregationInput, AggregationMethod, AggregationResult, AggregatorError,
665        EmbeddingAggregator, EmbeddingAggregatorConfig,
666    };
667
668    // Helpers
669    fn uniform(val: f64, dim: usize) -> Vec<f64> {
670        vec![val; dim]
671    }
672
673    fn make_agg(method: AggregationMethod) -> EmbeddingAggregator {
674        EmbeddingAggregator::new(method, EmbeddingAggregatorConfig::default())
675    }
676
677    fn make_inputs(embeddings: Vec<Vec<f64>>) -> Vec<AggregationInput> {
678        embeddings
679            .into_iter()
680            .enumerate()
681            .map(|(i, e)| AggregationInput::new(format!("i{i}"), e, 1.0))
682            .collect()
683    }
684
685    fn result_near(result: &AggregationResult, expected: &[f64], tol: f64) -> bool {
686        result
687            .output
688            .iter()
689            .zip(expected.iter())
690            .all(|(a, b)| (a - b).abs() < tol)
691    }
692
693    // ------------------------------------------------------------------
694    // 1  Mean
695    // ------------------------------------------------------------------
696
697    #[test]
698    fn test_mean_basic() {
699        let mut agg = make_agg(AggregationMethod::Mean);
700        let inputs = make_inputs(vec![vec![2.0, 4.0], vec![0.0, 0.0]]);
701        let r = agg
702            .aggregate(&inputs)
703            .expect("test: aggregate should succeed");
704        assert!(result_near(&r, &[1.0, 2.0], 1e-10));
705    }
706
707    #[test]
708    fn test_mean_single_input() {
709        let mut agg = make_agg(AggregationMethod::Mean);
710        let inputs = make_inputs(vec![vec![3.0, 7.0, 1.0]]);
711        let r = agg
712            .aggregate(&inputs)
713            .expect("test: aggregate should succeed");
714        assert!(result_near(&r, &[3.0, 7.0, 1.0], 1e-10));
715    }
716
717    #[test]
718    fn test_mean_three_equal_vecs() {
719        let mut agg = make_agg(AggregationMethod::Mean);
720        let inputs = make_inputs(vec![vec![1.0, 2.0], vec![1.0, 2.0], vec![1.0, 2.0]]);
721        let r = agg
722            .aggregate(&inputs)
723            .expect("test: aggregate should succeed");
724        assert!(result_near(&r, &[1.0, 2.0], 1e-10));
725    }
726
727    // ------------------------------------------------------------------
728    // 2  WeightedMean
729    // ------------------------------------------------------------------
730
731    #[test]
732    fn test_weighted_mean_basic() {
733        let mut agg = make_agg(AggregationMethod::WeightedMean {
734            weights: vec![3.0, 1.0],
735        });
736        let inputs = make_inputs(vec![vec![1.0, 0.0], vec![0.0, 1.0]]);
737        let r = agg
738            .aggregate(&inputs)
739            .expect("test: aggregate should succeed");
740        // weights normalised: 3/4, 1/4
741        assert!((r.output[0] - 0.75).abs() < 1e-10);
742        assert!((r.output[1] - 0.25).abs() < 1e-10);
743    }
744
745    #[test]
746    fn test_weighted_mean_equal_weights() {
747        let mut agg = make_agg(AggregationMethod::WeightedMean {
748            weights: vec![1.0, 1.0],
749        });
750        let inputs = make_inputs(vec![vec![2.0, 4.0], vec![0.0, 0.0]]);
751        let r = agg
752            .aggregate(&inputs)
753            .expect("test: aggregate should succeed");
754        assert!(result_near(&r, &[1.0, 2.0], 1e-10));
755    }
756
757    #[test]
758    fn test_weighted_mean_count_mismatch_error() {
759        let mut agg = make_agg(AggregationMethod::WeightedMean {
760            weights: vec![1.0, 2.0, 3.0],
761        });
762        let inputs = make_inputs(vec![vec![1.0], vec![2.0]]);
763        let err = agg
764            .aggregate(&inputs)
765            .expect_err("test: aggregate should return error");
766        assert_eq!(
767            err,
768            AggregatorError::WeightCountMismatch {
769                expected: 2,
770                got: 3,
771            }
772        );
773    }
774
775    // ------------------------------------------------------------------
776    // 3  Max
777    // ------------------------------------------------------------------
778
779    #[test]
780    fn test_max_basic() {
781        let mut agg = make_agg(AggregationMethod::Max);
782        let inputs = make_inputs(vec![vec![1.0, 5.0], vec![3.0, 2.0]]);
783        let r = agg
784            .aggregate(&inputs)
785            .expect("test: aggregate should succeed");
786        assert!(result_near(&r, &[3.0, 5.0], 1e-10));
787    }
788
789    #[test]
790    fn test_max_negative_values() {
791        let mut agg = make_agg(AggregationMethod::Max);
792        let inputs = make_inputs(vec![vec![-1.0, -5.0], vec![-3.0, -2.0]]);
793        let r = agg
794            .aggregate(&inputs)
795            .expect("test: aggregate should succeed");
796        assert!(result_near(&r, &[-1.0, -2.0], 1e-10));
797    }
798
799    // ------------------------------------------------------------------
800    // 4  Min
801    // ------------------------------------------------------------------
802
803    #[test]
804    fn test_min_basic() {
805        let mut agg = make_agg(AggregationMethod::Min);
806        let inputs = make_inputs(vec![vec![1.0, 5.0], vec![3.0, 2.0]]);
807        let r = agg
808            .aggregate(&inputs)
809            .expect("test: aggregate should succeed");
810        assert!(result_near(&r, &[1.0, 2.0], 1e-10));
811    }
812
813    #[test]
814    fn test_min_all_equal() {
815        let mut agg = make_agg(AggregationMethod::Min);
816        let inputs = make_inputs(vec![vec![4.0, 4.0], vec![4.0, 4.0]]);
817        let r = agg
818            .aggregate(&inputs)
819            .expect("test: aggregate should succeed");
820        assert!(result_near(&r, &[4.0, 4.0], 1e-10));
821    }
822
823    // ------------------------------------------------------------------
824    // 5  Sum
825    // ------------------------------------------------------------------
826
827    #[test]
828    fn test_sum_basic() {
829        let mut agg = make_agg(AggregationMethod::Sum);
830        let inputs = make_inputs(vec![vec![1.0, 2.0], vec![3.0, 4.0]]);
831        let r = agg
832            .aggregate(&inputs)
833            .expect("test: aggregate should succeed");
834        assert!(result_near(&r, &[4.0, 6.0], 1e-10));
835    }
836
837    #[test]
838    fn test_sum_three_vectors() {
839        let mut agg = make_agg(AggregationMethod::Sum);
840        let inputs = make_inputs(vec![vec![1.0], vec![2.0], vec![3.0]]);
841        let r = agg
842            .aggregate(&inputs)
843            .expect("test: aggregate should succeed");
844        assert!(result_near(&r, &[6.0], 1e-10));
845    }
846
847    // ------------------------------------------------------------------
848    // 6  GeometricMean
849    // ------------------------------------------------------------------
850
851    #[test]
852    fn test_geometric_mean_positive_values() {
853        let mut agg = make_agg(AggregationMethod::GeometricMean);
854        // geometric mean of 1 and 4 ≈ 2.0
855        let inputs = make_inputs(vec![vec![1.0], vec![4.0]]);
856        let r = agg
857            .aggregate(&inputs)
858            .expect("test: aggregate should succeed");
859        // expected ≈ exp((ln(1+ε) + ln(4+ε))/2) * sign(2.5)
860        assert!((r.output[0] - 2.0).abs() < 0.01);
861    }
862
863    #[test]
864    fn test_geometric_mean_negative_sign() {
865        let mut agg = make_agg(AggregationMethod::GeometricMean);
866        // arith mean is negative → output should be negative
867        let inputs = make_inputs(vec![vec![-4.0], vec![-1.0]]);
868        let r = agg
869            .aggregate(&inputs)
870            .expect("test: aggregate should succeed");
871        assert!(r.output[0] < 0.0);
872    }
873
874    #[test]
875    fn test_geometric_mean_mixed_sign() {
876        let mut agg = make_agg(AggregationMethod::GeometricMean);
877        // arith mean = 0.0 → sign treated as positive
878        let inputs = make_inputs(vec![vec![-1.0], vec![1.0]]);
879        let r = agg
880            .aggregate(&inputs)
881            .expect("test: aggregate should succeed");
882        // Should not panic
883        let _ = r.output[0];
884    }
885
886    // ------------------------------------------------------------------
887    // 7  AttentionPooling
888    // ------------------------------------------------------------------
889
890    #[test]
891    fn test_attention_pooling_uniform_query() {
892        // Uniform query → all attention weights equal → reduces to mean
893        let q = uniform(1.0, 4);
894        let mut agg = make_agg(AggregationMethod::AttentionPooling { query: q });
895        let inputs = make_inputs(vec![vec![1.0, 0.0, 0.0, 0.0], vec![0.0, 1.0, 0.0, 0.0]]);
896        let mean_agg = make_inputs(vec![vec![1.0, 0.0, 0.0, 0.0], vec![0.0, 1.0, 0.0, 0.0]]);
897        let r = agg
898            .aggregate(&inputs)
899            .expect("test: aggregate should succeed");
900        let mut mean_agg2 = make_agg(AggregationMethod::Mean);
901        let rm = mean_agg2
902            .aggregate(&mean_agg)
903            .expect("test: aggregate should succeed");
904        // Because dots differ they won't be exactly equal, but the pooling should
905        // produce a valid vector of the right length.
906        assert_eq!(r.output.len(), 4);
907        let _ = rm;
908    }
909
910    #[test]
911    fn test_attention_pooling_query_dim_mismatch() {
912        let q = vec![1.0, 0.0]; // wrong dim
913        let mut agg = make_agg(AggregationMethod::AttentionPooling { query: q });
914        let inputs = make_inputs(vec![vec![1.0, 2.0, 3.0]]);
915        assert_eq!(
916            agg.aggregate(&inputs)
917                .expect_err("test: aggregate should return error"),
918            AggregatorError::InvalidQuery
919        );
920    }
921
922    #[test]
923    fn test_attention_pooling_single_input() {
924        let q = vec![1.0, 0.0];
925        let mut agg = make_agg(AggregationMethod::AttentionPooling { query: q });
926        let inputs = make_inputs(vec![vec![3.0, 7.0]]);
927        let r = agg
928            .aggregate(&inputs)
929            .expect("test: aggregate should succeed");
930        assert!(result_near(&r, &[3.0, 7.0], 1e-9));
931    }
932
933    // ------------------------------------------------------------------
934    // 8  Error cases
935    // ------------------------------------------------------------------
936
937    #[test]
938    fn test_empty_input_error() {
939        let mut agg = make_agg(AggregationMethod::Mean);
940        let err = agg
941            .aggregate(&[])
942            .expect_err("test: aggregate should return error");
943        assert_eq!(err, AggregatorError::EmptyInput);
944    }
945
946    #[test]
947    fn test_dimension_mismatch_error() {
948        let mut agg = make_agg(AggregationMethod::Mean);
949        let inputs = vec![
950            AggregationInput::new("a", vec![1.0, 2.0], 1.0),
951            AggregationInput::new("b", vec![1.0, 2.0, 3.0], 1.0),
952        ];
953        let err = agg
954            .aggregate(&inputs)
955            .expect_err("test: aggregate should return error");
956        assert_eq!(
957            err,
958            AggregatorError::DimensionMismatch {
959                expected: 2,
960                got: 3
961            }
962        );
963    }
964
965    #[test]
966    fn test_aggregate_raw_empty() {
967        let mut agg = make_agg(AggregationMethod::Mean);
968        let err = agg
969            .aggregate_raw(&[])
970            .expect_err("test: aggregate_raw with empty input should return error");
971        assert_eq!(err, AggregatorError::EmptyInput);
972    }
973
974    #[test]
975    fn test_aggregate_raw_basic() {
976        let mut agg = make_agg(AggregationMethod::Mean);
977        let r = agg
978            .aggregate_raw(&[vec![2.0, 4.0], vec![0.0, 0.0]])
979            .expect("test: aggregate_raw should succeed");
980        assert!(result_near(&r, &[1.0, 2.0], 1e-10));
981    }
982
983    // ------------------------------------------------------------------
984    // 9  L2 utilities
985    // ------------------------------------------------------------------
986
987    #[test]
988    fn test_l2_norm_zero_vec() {
989        let norm = EmbeddingAggregator::l2_norm(&[0.0, 0.0, 0.0]);
990        assert_eq!(norm, 0.0);
991    }
992
993    #[test]
994    fn test_l2_norm_unit_vec() {
995        let norm = EmbeddingAggregator::l2_norm(&[1.0, 0.0, 0.0]);
996        assert!((norm - 1.0).abs() < 1e-12);
997    }
998
999    #[test]
1000    fn test_l2_normalize_already_unit() {
1001        let v = vec![1.0, 0.0, 0.0];
1002        let n = EmbeddingAggregator::l2_normalize(&v);
1003        assert!((EmbeddingAggregator::l2_norm(&n) - 1.0).abs() < 1e-10);
1004    }
1005
1006    #[test]
1007    fn test_l2_normalize_zero_vec_unchanged() {
1008        let v = vec![0.0, 0.0];
1009        let n = EmbeddingAggregator::l2_normalize(&v);
1010        assert_eq!(n, vec![0.0, 0.0]);
1011    }
1012
1013    #[test]
1014    fn test_l2_normalize_scales_correctly() {
1015        let v = vec![3.0, 4.0];
1016        let n = EmbeddingAggregator::l2_normalize(&v);
1017        let norm = EmbeddingAggregator::l2_norm(&n);
1018        assert!((norm - 1.0).abs() < 1e-10);
1019    }
1020
1021    // ------------------------------------------------------------------
1022    // 10 Cosine similarity
1023    // ------------------------------------------------------------------
1024
1025    #[test]
1026    fn test_cosine_similarity_identical() {
1027        let v = vec![1.0, 2.0, 3.0];
1028        let sim = EmbeddingAggregator::cosine_similarity(&v, &v);
1029        assert!((sim - 1.0).abs() < 1e-10);
1030    }
1031
1032    #[test]
1033    fn test_cosine_similarity_orthogonal() {
1034        let a = vec![1.0, 0.0];
1035        let b = vec![0.0, 1.0];
1036        let sim = EmbeddingAggregator::cosine_similarity(&a, &b);
1037        assert!(sim.abs() < 1e-10);
1038    }
1039
1040    #[test]
1041    fn test_cosine_similarity_opposite() {
1042        let a = vec![1.0, 0.0];
1043        let b = vec![-1.0, 0.0];
1044        let sim = EmbeddingAggregator::cosine_similarity(&a, &b);
1045        assert!((sim + 1.0).abs() < 1e-10);
1046    }
1047
1048    #[test]
1049    fn test_cosine_similarity_zero_vec() {
1050        let a = vec![1.0, 0.0];
1051        let b = vec![0.0, 0.0];
1052        let sim = EmbeddingAggregator::cosine_similarity(&a, &b);
1053        assert_eq!(sim, 0.0);
1054    }
1055
1056    // ------------------------------------------------------------------
1057    // 11 Attention scores
1058    // ------------------------------------------------------------------
1059
1060    #[test]
1061    fn test_attention_scores_sum_to_one() {
1062        let query = vec![1.0, 0.0, 1.0];
1063        let embeddings = vec![
1064            vec![1.0, 0.0, 0.0],
1065            vec![0.0, 1.0, 0.0],
1066            vec![0.0, 0.0, 1.0],
1067        ];
1068        let scores = EmbeddingAggregator::attention_scores(&query, &embeddings);
1069        let total: f64 = scores.iter().sum();
1070        assert!((total - 1.0).abs() < 1e-10);
1071    }
1072
1073    #[test]
1074    fn test_attention_scores_length() {
1075        let query = vec![1.0, 1.0];
1076        let embeddings = vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 1.0]];
1077        let scores = EmbeddingAggregator::attention_scores(&query, &embeddings);
1078        assert_eq!(scores.len(), 3);
1079    }
1080
1081    // ------------------------------------------------------------------
1082    // 12 Normalize output config
1083    // ------------------------------------------------------------------
1084
1085    #[test]
1086    fn test_normalize_output_enabled() {
1087        let config = EmbeddingAggregatorConfig {
1088            normalize_output: true,
1089            handle_zeros: false,
1090        };
1091        let mut agg = EmbeddingAggregator::new(AggregationMethod::Sum, config);
1092        let r = agg
1093            .aggregate_raw(&[vec![3.0, 4.0], vec![0.0, 0.0]])
1094            .expect("test: aggregate_raw should succeed");
1095        let norm = EmbeddingAggregator::l2_norm(&r.output);
1096        assert!((norm - 1.0).abs() < 1e-10);
1097    }
1098
1099    #[test]
1100    fn test_handle_zeros_replaces_zero_vec() {
1101        let config = EmbeddingAggregatorConfig {
1102            normalize_output: false,
1103            handle_zeros: true,
1104        };
1105        let mut agg = EmbeddingAggregator::new(AggregationMethod::Mean, config);
1106        let inputs = vec![
1107            AggregationInput::new("z", vec![0.0, 0.0], 1.0),
1108            AggregationInput::new("v", vec![1.0, 0.0], 1.0),
1109        ];
1110        let r = agg
1111            .aggregate(&inputs)
1112            .expect("test: aggregate should succeed");
1113        // zero vec replaced by [1/sqrt(2), 1/sqrt(2)]
1114        let expected_0 = (1.0 / 2.0_f64.sqrt() + 1.0) / 2.0;
1115        let expected_1 = 1.0 / (2.0 * 2.0_f64.sqrt());
1116        assert!((r.output[0] - expected_0).abs() < 1e-9);
1117        assert!((r.output[1] - expected_1).abs() < 1e-9);
1118    }
1119
1120    // ------------------------------------------------------------------
1121    // 13 Result metadata
1122    // ------------------------------------------------------------------
1123
1124    #[test]
1125    fn test_result_input_count() {
1126        let mut agg = make_agg(AggregationMethod::Mean);
1127        let inputs = make_inputs(vec![vec![1.0], vec![2.0], vec![3.0]]);
1128        let r = agg
1129            .aggregate(&inputs)
1130            .expect("test: aggregate should succeed");
1131        assert_eq!(r.input_count, 3);
1132    }
1133
1134    #[test]
1135    fn test_result_dims() {
1136        let mut agg = make_agg(AggregationMethod::Sum);
1137        let inputs = make_inputs(vec![vec![1.0, 2.0, 3.0, 4.0]]);
1138        let r = agg
1139            .aggregate(&inputs)
1140            .expect("test: aggregate should succeed");
1141        assert_eq!(r.dims, 4);
1142    }
1143
1144    #[test]
1145    fn test_result_norm_matches_output() {
1146        let mut agg = make_agg(AggregationMethod::Mean);
1147        let inputs = make_inputs(vec![vec![3.0, 4.0]]);
1148        let r = agg
1149            .aggregate(&inputs)
1150            .expect("test: aggregate should succeed");
1151        let expected_norm = EmbeddingAggregator::l2_norm(&r.output);
1152        assert!((r.norm - expected_norm).abs() < 1e-10);
1153    }
1154
1155    #[test]
1156    fn test_result_method_name() {
1157        let mut agg = make_agg(AggregationMethod::Max);
1158        let inputs = make_inputs(vec![vec![1.0]]);
1159        let r = agg
1160            .aggregate(&inputs)
1161            .expect("test: aggregate should succeed");
1162        assert_eq!(r.method, "Max");
1163    }
1164
1165    // ------------------------------------------------------------------
1166    // 14 History
1167    // ------------------------------------------------------------------
1168
1169    #[test]
1170    fn test_history_grows() {
1171        let mut agg = make_agg(AggregationMethod::Mean);
1172        for _ in 0..5 {
1173            agg.aggregate_raw(&[vec![1.0]])
1174                .expect("test: aggregate_raw should succeed");
1175        }
1176        assert_eq!(agg.history.len(), 5);
1177    }
1178
1179    #[test]
1180    fn test_history_capped_at_max() {
1181        let mut agg = EmbeddingAggregator::with_history_capacity(
1182            AggregationMethod::Mean,
1183            EmbeddingAggregatorConfig::default(),
1184            3,
1185        );
1186        for _ in 0..10 {
1187            agg.aggregate_raw(&[vec![1.0]])
1188                .expect("test: aggregate_raw should succeed");
1189        }
1190        assert_eq!(agg.history.len(), 3);
1191    }
1192
1193    #[test]
1194    fn test_recent_history_returns_n() {
1195        let mut agg = make_agg(AggregationMethod::Sum);
1196        for _ in 0..8 {
1197            agg.aggregate_raw(&[vec![1.0]])
1198                .expect("test: aggregate_raw should succeed");
1199        }
1200        let recent = agg.recent_history(3);
1201        assert_eq!(recent.len(), 3);
1202    }
1203
1204    #[test]
1205    fn test_recent_history_more_than_total() {
1206        let mut agg = make_agg(AggregationMethod::Sum);
1207        agg.aggregate_raw(&[vec![1.0]])
1208            .expect("test: aggregate_raw should succeed");
1209        let recent = agg.recent_history(100);
1210        assert_eq!(recent.len(), 1);
1211    }
1212
1213    // ------------------------------------------------------------------
1214    // 15 Stats
1215    // ------------------------------------------------------------------
1216
1217    #[test]
1218    fn test_stats_empty_history() {
1219        let agg = make_agg(AggregationMethod::Mean);
1220        let s = agg.stats();
1221        assert_eq!(s.total_aggregations, 0);
1222        assert_eq!(s.avg_input_count, 0.0);
1223        assert_eq!(s.avg_output_norm, 0.0);
1224        assert_eq!(s.method_name, "Mean");
1225    }
1226
1227    #[test]
1228    fn test_stats_total_aggregations() {
1229        let mut agg = make_agg(AggregationMethod::Min);
1230        for _ in 0..7 {
1231            agg.aggregate_raw(&[vec![1.0]])
1232                .expect("test: aggregate_raw should succeed");
1233        }
1234        assert_eq!(agg.stats().total_aggregations, 7);
1235    }
1236
1237    #[test]
1238    fn test_stats_avg_input_count() {
1239        let mut agg = make_agg(AggregationMethod::Mean);
1240        agg.aggregate_raw(&[vec![1.0], vec![2.0]])
1241            .expect("test: aggregate_raw should succeed"); // count 2
1242        agg.aggregate_raw(&[vec![1.0]])
1243            .expect("test: aggregate_raw should succeed"); // count 1
1244        let s = agg.stats();
1245        assert!((s.avg_input_count - 1.5).abs() < 1e-10);
1246    }
1247
1248    // ------------------------------------------------------------------
1249    // 16 merge_results
1250    // ------------------------------------------------------------------
1251
1252    #[test]
1253    fn test_merge_results_mean() {
1254        let mut agg = make_agg(AggregationMethod::Mean);
1255        let r1 = agg
1256            .aggregate_raw(&[vec![2.0, 0.0]])
1257            .expect("test: aggregate_raw should succeed");
1258        let r2 = agg
1259            .aggregate_raw(&[vec![0.0, 4.0]])
1260            .expect("test: aggregate_raw should succeed");
1261        let merged = EmbeddingAggregator::merge_results(&[r1, r2], AggregationMethod::Mean)
1262            .expect("test: merge_results should succeed");
1263        assert!(result_near(&merged, &[1.0, 2.0], 1e-10));
1264    }
1265
1266    #[test]
1267    fn test_merge_results_empty_error() {
1268        let err = EmbeddingAggregator::merge_results(&[], AggregationMethod::Sum)
1269            .expect_err("test: merge_results with empty input should return error");
1270        assert_eq!(err, AggregatorError::EmptyInput);
1271    }
1272
1273    #[test]
1274    fn test_merge_results_dim_mismatch() {
1275        let r1 = AggregationResult {
1276            method: "Mean".into(),
1277            output: vec![1.0, 2.0],
1278            input_count: 1,
1279            dims: 2,
1280            norm: 1.0,
1281        };
1282        let r2 = AggregationResult {
1283            method: "Mean".into(),
1284            output: vec![1.0],
1285            input_count: 1,
1286            dims: 1,
1287            norm: 1.0,
1288        };
1289        let err = EmbeddingAggregator::merge_results(&[r1, r2], AggregationMethod::Sum)
1290            .expect_err("test: merge_results with dim mismatch should return error");
1291        assert_eq!(
1292            err,
1293            AggregatorError::DimensionMismatch {
1294                expected: 2,
1295                got: 1
1296            }
1297        );
1298    }
1299
1300    // ------------------------------------------------------------------
1301    // 17 aggregate_with_input_weights
1302    // ------------------------------------------------------------------
1303
1304    #[test]
1305    fn test_aggregate_with_input_weights() {
1306        let mut agg = make_agg(AggregationMethod::Mean);
1307        let inputs = vec![
1308            AggregationInput::new("a", vec![1.0, 0.0], 3.0),
1309            AggregationInput::new("b", vec![0.0, 1.0], 1.0),
1310        ];
1311        let r = agg
1312            .aggregate_with_input_weights(&inputs)
1313            .expect("test: aggregate_with_input_weights should succeed");
1314        assert!((r.output[0] - 0.75).abs() < 1e-10);
1315        assert!((r.output[1] - 0.25).abs() < 1e-10);
1316    }
1317
1318    #[test]
1319    fn test_aggregate_with_input_weights_empty_error() {
1320        let mut agg = make_agg(AggregationMethod::Mean);
1321        let err = agg
1322            .aggregate_with_input_weights(&[])
1323            .expect_err("test: aggregate_with_input_weights with empty input should return error");
1324        assert_eq!(err, AggregatorError::EmptyInput);
1325    }
1326
1327    // ------------------------------------------------------------------
1328    // 18 Error Display
1329    // ------------------------------------------------------------------
1330
1331    #[test]
1332    fn test_error_display_empty_input() {
1333        let e = AggregatorError::EmptyInput;
1334        assert!(!e.to_string().is_empty());
1335    }
1336
1337    #[test]
1338    fn test_error_display_dimension_mismatch() {
1339        let e = AggregatorError::DimensionMismatch {
1340            expected: 4,
1341            got: 3,
1342        };
1343        assert!(e.to_string().contains('4'));
1344    }
1345
1346    #[test]
1347    fn test_error_display_weight_count() {
1348        let e = AggregatorError::WeightCountMismatch {
1349            expected: 2,
1350            got: 5,
1351        };
1352        assert!(e.to_string().contains('5'));
1353    }
1354
1355    #[test]
1356    fn test_error_display_invalid_query() {
1357        let e = AggregatorError::InvalidQuery;
1358        assert!(!e.to_string().is_empty());
1359    }
1360
1361    // ------------------------------------------------------------------
1362    // 19 Method names
1363    // ------------------------------------------------------------------
1364
1365    #[test]
1366    fn test_method_names() {
1367        let methods: Vec<(&str, AggregationMethod)> = vec![
1368            ("Mean", AggregationMethod::Mean),
1369            ("Max", AggregationMethod::Max),
1370            ("Min", AggregationMethod::Min),
1371            ("Sum", AggregationMethod::Sum),
1372            ("GeometricMean", AggregationMethod::GeometricMean),
1373            (
1374                "WeightedMean",
1375                AggregationMethod::WeightedMean { weights: vec![1.0] },
1376            ),
1377            (
1378                "AttentionPooling",
1379                AggregationMethod::AttentionPooling { query: vec![1.0] },
1380            ),
1381        ];
1382        for (expected, method) in methods {
1383            assert_eq!(method.name(), expected);
1384        }
1385    }
1386
1387    // ------------------------------------------------------------------
1388    // 20 Zero-history capacity
1389    // ------------------------------------------------------------------
1390
1391    #[test]
1392    fn test_zero_history_capacity() {
1393        let mut agg = EmbeddingAggregator::with_history_capacity(
1394            AggregationMethod::Sum,
1395            EmbeddingAggregatorConfig::default(),
1396            0,
1397        );
1398        agg.aggregate_raw(&[vec![1.0]])
1399            .expect("test: aggregate_raw should succeed");
1400        assert_eq!(agg.history.len(), 0);
1401        let s = agg.stats();
1402        assert_eq!(s.total_aggregations, 0);
1403    }
1404}