Skip to main content

sklears_feature_selection/evaluation/
metrics_aggregation.rs

1//! Metrics aggregation for combining multiple evaluation measures
2//!
3//! This module implements classical aggregation algorithms used to combine the
4//! outputs of several feature-selection evaluators or rankers into a single
5//! consensus. All routines are exact implementations of well-defined methods:
6//!
7//! * [`MetricsAggregator`] / [`WeightedAveraging`] — (weighted) arithmetic mean
8//!   and median aggregation of scalar metric values.
9//! * [`RankAggregation`] — Borda count and Kemeny-optimal rank aggregation.
10//! * [`ConsensusMetrics`] — mean pairwise Kendall-tau agreement of a set of
11//!   rankings.
12//! * [`MultiCriteriaEvaluation`] — TOPSIS multi-criteria decision analysis.
13//!
14//! Ranking inputs follow the convention used throughout the crate: a ranking is
15//! an ordered list of item indices where the element at position `p` is the item
16//! placed at rank `p` (rank `0` is the best / most preferred item). Every ranking
17//! passed to the rank-aggregation routines must be a permutation of the same item
18//! set `{0, 1, ..., n-1}`.
19
20use sklears_core::error::{Result as SklResult, SklearsError};
21
22/// Aggregation strategy used by [`MetricsAggregator::aggregate_with`].
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum AggregationStrategy {
25    /// Unweighted arithmetic mean of the metric values.
26    Mean,
27    /// Median of the metric values.
28    Median,
29    /// Weighted arithmetic mean (requires weights to be supplied).
30    Weighted,
31    /// Minimum metric value (pessimistic aggregation).
32    Min,
33    /// Maximum metric value (optimistic aggregation).
34    Max,
35}
36
37/// Aggregator combining several scalar evaluation metrics into one value.
38#[derive(Debug, Clone)]
39pub struct MetricsAggregator;
40
41impl MetricsAggregator {
42    /// Aggregate metric values into a single scalar.
43    ///
44    /// When `weights` is `Some`, a weighted arithmetic mean is computed (the
45    /// weights are normalised to sum to one); when it is `None`, the unweighted
46    /// arithmetic mean is returned. Returns an error for empty input, mismatched
47    /// weight lengths, or weights that are negative / sum to zero.
48    pub fn aggregate_metrics(metrics: &[f64], weights: Option<&[f64]>) -> SklResult<f64> {
49        match weights {
50            Some(w) => WeightedAveraging::weighted_average(metrics, w),
51            None => Self::aggregate_with(metrics, AggregationStrategy::Mean, None),
52        }
53    }
54
55    /// Aggregate metric values using an explicit `AggregationStrategy`.
56    ///
57    /// `weights` is only consulted for `AggregationStrategy::Weighted`; for all
58    /// other strategies it is ignored. Returns an error if `metrics` is empty, or
59    /// if the weighted strategy is requested without valid weights.
60    pub fn aggregate_with(
61        metrics: &[f64],
62        strategy: AggregationStrategy,
63        weights: Option<&[f64]>,
64    ) -> SklResult<f64> {
65        if metrics.is_empty() {
66            return Err(SklearsError::InvalidInput(
67                "aggregate_metrics requires at least one metric value".to_string(),
68            ));
69        }
70
71        match strategy {
72            AggregationStrategy::Mean => Ok(metrics.iter().sum::<f64>() / metrics.len() as f64),
73            AggregationStrategy::Median => Ok(median(metrics)),
74            AggregationStrategy::Min => Ok(metrics.iter().copied().fold(f64::INFINITY, f64::min)),
75            AggregationStrategy::Max => {
76                Ok(metrics.iter().copied().fold(f64::NEG_INFINITY, f64::max))
77            }
78            AggregationStrategy::Weighted => {
79                let w = weights.ok_or_else(|| {
80                    SklearsError::InvalidInput("weighted aggregation requires weights".to_string())
81                })?;
82                WeightedAveraging::weighted_average(metrics, w)
83            }
84        }
85    }
86}
87
88/// Compute the median of a slice without mutating the caller's data.
89fn median(values: &[f64]) -> f64 {
90    let mut sorted: Vec<f64> = values.to_vec();
91    sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
92    let n = sorted.len();
93    if n % 2 == 1 {
94        sorted[n / 2]
95    } else {
96        0.5 * (sorted[n / 2 - 1] + sorted[n / 2])
97    }
98}
99
100/// Weighted averaging of metric values.
101#[derive(Debug, Clone)]
102pub struct WeightedAveraging;
103
104impl WeightedAveraging {
105    /// Compute the weighted arithmetic mean of `values` using `weights`.
106    ///
107    /// The weights are normalised internally so they sum to one, hence only their
108    /// relative magnitudes matter. Returns an error when the inputs are empty, the
109    /// lengths differ, any weight is negative or non-finite, or the weights sum to
110    /// zero (in which case no meaningful average is defined).
111    pub fn weighted_average(values: &[f64], weights: &[f64]) -> SklResult<f64> {
112        if values.is_empty() {
113            return Err(SklearsError::InvalidInput(
114                "weighted_average requires at least one value".to_string(),
115            ));
116        }
117        if values.len() != weights.len() {
118            return Err(SklearsError::InvalidInput(format!(
119                "weighted_average: values length ({}) must match weights length ({})",
120                values.len(),
121                weights.len()
122            )));
123        }
124
125        let mut weight_sum = 0.0;
126        for &w in weights {
127            if !w.is_finite() {
128                return Err(SklearsError::InvalidInput(
129                    "weighted_average: weights must be finite".to_string(),
130                ));
131            }
132            if w < 0.0 {
133                return Err(SklearsError::InvalidInput(
134                    "weighted_average: weights must be non-negative".to_string(),
135                ));
136            }
137            weight_sum += w;
138        }
139
140        if weight_sum <= 0.0 {
141            return Err(SklearsError::InvalidInput(
142                "weighted_average: sum of weights must be positive".to_string(),
143            ));
144        }
145
146        let weighted_sum: f64 = values
147            .iter()
148            .zip(weights.iter())
149            .map(|(&v, &w)| v * w)
150            .sum();
151
152        Ok(weighted_sum / weight_sum)
153    }
154}
155
156/// Rank aggregation methods producing a single consensus ranking.
157#[derive(Debug, Clone)]
158pub struct RankAggregation;
159
160impl RankAggregation {
161    /// Borda count rank aggregation.
162    ///
163    /// Each ranking awards `n - rank` points to the item at a given position
164    /// (the best item, at rank `0`, receives `n` points; the worst receives `1`).
165    /// Points are summed across all rankings and the consensus ranking lists items
166    /// in order of decreasing total score. Ties are broken by smaller item index so
167    /// the output is deterministic.
168    ///
169    /// Every ranking must be a permutation of `{0, ..., n-1}` for the same `n`.
170    pub fn borda_count(rankings: &[Vec<usize>]) -> SklResult<Vec<usize>> {
171        let n = validate_rankings(rankings)?;
172
173        let mut scores = vec![0usize; n];
174        for ranking in rankings {
175            for (position, &item) in ranking.iter().enumerate() {
176                // Item at position `position` (rank) receives `n - position` points.
177                scores[item] += n - position;
178            }
179        }
180
181        let mut order: Vec<usize> = (0..n).collect();
182        // Sort by descending score, then ascending index for deterministic ties.
183        order.sort_by(|&a, &b| scores[b].cmp(&scores[a]).then(a.cmp(&b)));
184        Ok(order)
185    }
186
187    /// Kemeny-optimal rank aggregation.
188    ///
189    /// Returns the consensus ranking that minimises the total Kendall-tau distance
190    /// (number of pairwise disagreements) to the input rankings. For `n <= 8` the
191    /// optimum is found exactly by enumerating all `n!` permutations. For larger
192    /// `n` an exhaustive search is intractable, so a documented local-search
193    /// heuristic is used instead: starting from the Borda consensus, adjacent items
194    /// are repeatedly swapped whenever the swap strictly reduces the total
195    /// Kendall-tau cost, until no improving adjacent swap remains (a local Kemeny
196    /// optimum). The result is therefore exact for small `n` and an approximation
197    /// for large `n`.
198    pub fn kemeny_optimal(rankings: &[Vec<usize>]) -> SklResult<Vec<usize>> {
199        let n = validate_rankings(rankings)?;
200        if n == 0 {
201            return Ok(Vec::new());
202        }
203
204        // Pairwise preference matrix: prefer[i][j] = number of rankings placing i before j.
205        let prefer = pairwise_preference_matrix(rankings, n);
206
207        const EXACT_LIMIT: usize = 8;
208        if n <= EXACT_LIMIT {
209            Self::kemeny_exact(&prefer, n)
210        } else {
211            Self::kemeny_local_search(&prefer, n, rankings)
212        }
213    }
214
215    /// Exhaustive Kemeny solver for small `n` (`n!` permutations).
216    fn kemeny_exact(prefer: &[Vec<usize>], n: usize) -> SklResult<Vec<usize>> {
217        let mut items: Vec<usize> = (0..n).collect();
218        let mut best_order = items.clone();
219        let mut best_cost = kemeny_cost(&best_order, prefer);
220
221        // Heap's algorithm to iterate all permutations in place.
222        let mut c = vec![0usize; n];
223        // Evaluate the initial permutation already done above.
224        let mut i = 0;
225        while i < n {
226            if c[i] < i {
227                if i % 2 == 0 {
228                    items.swap(0, i);
229                } else {
230                    items.swap(c[i], i);
231                }
232                let cost = kemeny_cost(&items, prefer);
233                if cost < best_cost
234                    || (cost == best_cost && lexicographically_smaller(&items, &best_order))
235                {
236                    best_cost = cost;
237                    best_order = items.clone();
238                }
239                c[i] += 1;
240                i = 0;
241            } else {
242                c[i] = 0;
243                i += 1;
244            }
245        }
246
247        Ok(best_order)
248    }
249
250    /// Local Kemeny search for large `n` using improving adjacent swaps.
251    fn kemeny_local_search(
252        prefer: &[Vec<usize>],
253        n: usize,
254        rankings: &[Vec<usize>],
255    ) -> SklResult<Vec<usize>> {
256        // Seed with the Borda consensus, which is a strong starting point.
257        let mut order = Self::borda_count(rankings)?;
258
259        let mut improved = true;
260        while improved {
261            improved = false;
262            for pos in 0..n.saturating_sub(1) {
263                let a = order[pos];
264                let b = order[pos + 1];
265                // Swapping adjacent a,b only changes the relative order of (a, b).
266                // Cost contribution of a-before-b is the count preferring b-before-a.
267                let cost_ab = prefer[b][a];
268                let cost_ba = prefer[a][b];
269                if cost_ba < cost_ab {
270                    order.swap(pos, pos + 1);
271                    improved = true;
272                }
273            }
274        }
275
276        Ok(order)
277    }
278}
279
280/// Consensus measurement across a set of rankings.
281#[derive(Debug, Clone)]
282pub struct ConsensusMetrics;
283
284impl ConsensusMetrics {
285    /// Compute the consensus (agreement) of a set of rankings.
286    ///
287    /// The consensus is defined as the mean Kendall-tau correlation coefficient
288    /// over all distinct pairs of rankings. For a pair, the coefficient is
289    /// `(concordant - discordant) / (n choose 2)`, which equals `+1` for identical
290    /// rankings and `-1` for exactly reversed rankings. The returned value lies in
291    /// `[-1, 1]`; `1.0` denotes perfect agreement. A single ranking trivially has a
292    /// consensus of `1.0`.
293    pub fn compute_consensus(rankings: &[Vec<usize>]) -> SklResult<f64> {
294        let n = validate_rankings(rankings)?;
295        let m = rankings.len();
296        if m == 1 || n <= 1 {
297            return Ok(1.0);
298        }
299
300        // Position lookup: rank_of[r][item] = position of `item` in ranking `r`.
301        let mut rank_of = vec![vec![0usize; n]; m];
302        for (r, ranking) in rankings.iter().enumerate() {
303            for (position, &item) in ranking.iter().enumerate() {
304                rank_of[r][item] = position;
305            }
306        }
307
308        let total_pairs = (n * (n - 1) / 2) as f64;
309        let mut tau_sum = 0.0;
310        let mut comparisons = 0usize;
311
312        for r1 in 0..m {
313            for r2 in (r1 + 1)..m {
314                let mut concordant: i64 = 0;
315                let mut discordant: i64 = 0;
316                for i in 0..n {
317                    for j in (i + 1)..n {
318                        let a = rank_of[r1][i].cmp(&rank_of[r1][j]);
319                        let b = rank_of[r2][i].cmp(&rank_of[r2][j]);
320                        if a == b {
321                            concordant += 1;
322                        } else {
323                            discordant += 1;
324                        }
325                    }
326                }
327                tau_sum += (concordant - discordant) as f64 / total_pairs;
328                comparisons += 1;
329            }
330        }
331
332        Ok(tau_sum / comparisons as f64)
333    }
334}
335
336/// Multi-criteria decision analysis over a set of alternatives.
337#[derive(Debug, Clone)]
338pub struct MultiCriteriaEvaluation;
339
340impl MultiCriteriaEvaluation {
341    /// Evaluate alternatives using the TOPSIS method.
342    ///
343    /// `criteria_scores` is laid out as one inner vector per criterion, each of
344    /// length `a` (the number of alternatives); `criteria_scores[c][i]` is the raw
345    /// score of alternative `i` on criterion `c`. `criteria_weights` provides one
346    /// weight per criterion. All criteria are treated as benefit criteria (larger
347    /// is better).
348    ///
349    /// TOPSIS proceeds by: vector-normalising each criterion column, scaling by the
350    /// normalised criterion weights, determining the positive- and negative-ideal
351    /// solutions, and scoring each alternative by its relative closeness
352    /// `d_neg / (d_pos + d_neg)`. The returned vector has one closeness score in
353    /// `[0, 1]` per alternative; higher is better. Returns an error on empty input,
354    /// inconsistent criterion lengths, or invalid weights.
355    pub fn evaluate_multi_criteria(
356        criteria_scores: &[Vec<f64>],
357        criteria_weights: &[f64],
358    ) -> SklResult<Vec<f64>> {
359        let n_criteria = criteria_scores.len();
360        if n_criteria == 0 {
361            return Err(SklearsError::InvalidInput(
362                "evaluate_multi_criteria requires at least one criterion".to_string(),
363            ));
364        }
365        if criteria_weights.len() != n_criteria {
366            return Err(SklearsError::InvalidInput(format!(
367                "evaluate_multi_criteria: number of weights ({}) must equal number of criteria ({})",
368                criteria_weights.len(),
369                n_criteria
370            )));
371        }
372
373        let n_alternatives = criteria_scores[0].len();
374        if n_alternatives == 0 {
375            return Err(SklearsError::InvalidInput(
376                "evaluate_multi_criteria requires at least one alternative".to_string(),
377            ));
378        }
379        for (c, column) in criteria_scores.iter().enumerate() {
380            if column.len() != n_alternatives {
381                return Err(SklearsError::InvalidInput(format!(
382                    "evaluate_multi_criteria: criterion {} has {} alternatives, expected {}",
383                    c,
384                    column.len(),
385                    n_alternatives
386                )));
387            }
388        }
389
390        // Normalise the weights to sum to one (also validates them).
391        let mut weight_sum = 0.0;
392        for &w in criteria_weights {
393            if !w.is_finite() || w < 0.0 {
394                return Err(SklearsError::InvalidInput(
395                    "evaluate_multi_criteria: weights must be finite and non-negative".to_string(),
396                ));
397            }
398            weight_sum += w;
399        }
400        if weight_sum <= 0.0 {
401            return Err(SklearsError::InvalidInput(
402                "evaluate_multi_criteria: sum of weights must be positive".to_string(),
403            ));
404        }
405        let norm_weights: Vec<f64> = criteria_weights.iter().map(|&w| w / weight_sum).collect();
406
407        // Vector normalisation per criterion, then apply the criterion weight.
408        // weighted[c][i] = (score / column_norm) * weight.
409        let mut weighted = vec![vec![0.0f64; n_alternatives]; n_criteria];
410        for c in 0..n_criteria {
411            let column_norm = criteria_scores[c]
412                .iter()
413                .map(|&v| v * v)
414                .sum::<f64>()
415                .sqrt();
416            for i in 0..n_alternatives {
417                let normalised = if column_norm > 0.0 {
418                    criteria_scores[c][i] / column_norm
419                } else {
420                    // A constant-zero criterion contributes nothing; keep it at zero.
421                    0.0
422                };
423                weighted[c][i] = normalised * norm_weights[c];
424            }
425        }
426
427        // Positive- and negative-ideal solutions per criterion (benefit criteria).
428        let mut ideal_best = vec![0.0f64; n_criteria];
429        let mut ideal_worst = vec![0.0f64; n_criteria];
430        for c in 0..n_criteria {
431            ideal_best[c] = weighted[c]
432                .iter()
433                .copied()
434                .fold(f64::NEG_INFINITY, f64::max);
435            ideal_worst[c] = weighted[c].iter().copied().fold(f64::INFINITY, f64::min);
436        }
437
438        // Relative closeness of each alternative.
439        let mut closeness = vec![0.0f64; n_alternatives];
440        for i in 0..n_alternatives {
441            let mut d_pos = 0.0;
442            let mut d_neg = 0.0;
443            for c in 0..n_criteria {
444                let v = weighted[c][i];
445                d_pos += (v - ideal_best[c]).powi(2);
446                d_neg += (v - ideal_worst[c]).powi(2);
447            }
448            d_pos = d_pos.sqrt();
449            d_neg = d_neg.sqrt();
450            let denom = d_pos + d_neg;
451            closeness[i] = if denom > 0.0 {
452                d_neg / denom
453            } else {
454                // All alternatives identical on every criterion -> tie at 1.0.
455                1.0
456            };
457        }
458
459        Ok(closeness)
460    }
461}
462
463/// Validate that `rankings` is non-empty and every ranking is a permutation of
464/// `{0, ..., n-1}` for a common `n`. Returns the item count `n` on success.
465fn validate_rankings(rankings: &[Vec<usize>]) -> SklResult<usize> {
466    if rankings.is_empty() {
467        return Err(SklearsError::InvalidInput(
468            "rank aggregation requires at least one ranking".to_string(),
469        ));
470    }
471
472    let n = rankings[0].len();
473    for (idx, ranking) in rankings.iter().enumerate() {
474        if ranking.len() != n {
475            return Err(SklearsError::InvalidInput(format!(
476                "ranking {} has length {} but expected {}",
477                idx,
478                ranking.len(),
479                n
480            )));
481        }
482        let mut seen = vec![false; n];
483        for &item in ranking {
484            if item >= n {
485                return Err(SklearsError::InvalidInput(format!(
486                    "ranking {idx} contains item index {item} out of range 0..{n}"
487                )));
488            }
489            if seen[item] {
490                return Err(SklearsError::InvalidInput(format!(
491                    "ranking {idx} contains duplicate item index {item}"
492                )));
493            }
494            seen[item] = true;
495        }
496    }
497
498    Ok(n)
499}
500
501/// Build the pairwise preference matrix where `prefer[i][j]` counts how many
502/// rankings place item `i` strictly before item `j`.
503fn pairwise_preference_matrix(rankings: &[Vec<usize>], n: usize) -> Vec<Vec<usize>> {
504    let mut prefer = vec![vec![0usize; n]; n];
505    for ranking in rankings {
506        let mut rank_of = vec![0usize; n];
507        for (position, &item) in ranking.iter().enumerate() {
508            rank_of[item] = position;
509        }
510        for i in 0..n {
511            for j in 0..n {
512                if i != j && rank_of[i] < rank_of[j] {
513                    prefer[i][j] += 1;
514                }
515            }
516        }
517    }
518    prefer
519}
520
521/// Kendall-tau cost of `order` against the pairwise preference matrix: for every
522/// ordered pair placed `i` before `j` in `order`, the cost is the number of input
523/// rankings that disagree (i.e. that prefer `j` before `i`).
524fn kemeny_cost(order: &[usize], prefer: &[Vec<usize>]) -> usize {
525    let n = order.len();
526    let mut cost = 0usize;
527    for a in 0..n {
528        for b in (a + 1)..n {
529            let i = order[a];
530            let j = order[b];
531            // `i` is before `j` in `order`; disagreements are rankings preferring `j` before `i`.
532            cost += prefer[j][i];
533        }
534    }
535    cost
536}
537
538/// Lexicographic comparison used to break exact-Kemeny ties deterministically.
539fn lexicographically_smaller(candidate: &[usize], current_best: &[usize]) -> bool {
540    for (c, b) in candidate.iter().zip(current_best.iter()) {
541        match c.cmp(b) {
542            std::cmp::Ordering::Less => return true,
543            std::cmp::Ordering::Greater => return false,
544            std::cmp::Ordering::Equal => {}
545        }
546    }
547    false
548}
549
550#[cfg(test)]
551mod tests {
552    use super::*;
553
554    #[test]
555    fn weighted_average_basic() {
556        // values 1,2,3 with weights 1,1,2 -> (1+2+6)/4 = 2.25
557        let result =
558            WeightedAveraging::weighted_average(&[1.0, 2.0, 3.0], &[1.0, 1.0, 2.0]).unwrap();
559        assert!((result - 2.25).abs() < 1e-12);
560    }
561
562    #[test]
563    fn weighted_average_normalises_weights() {
564        // Scaling all weights must not change the result.
565        let a = WeightedAveraging::weighted_average(&[4.0, 8.0], &[1.0, 3.0]).unwrap();
566        let b = WeightedAveraging::weighted_average(&[4.0, 8.0], &[10.0, 30.0]).unwrap();
567        // (4*1 + 8*3) / 4 = 28/4 = 7.0
568        assert!((a - 7.0).abs() < 1e-12);
569        assert!((a - b).abs() < 1e-12);
570    }
571
572    #[test]
573    fn weighted_average_rejects_zero_weights() {
574        assert!(WeightedAveraging::weighted_average(&[1.0, 2.0], &[0.0, 0.0]).is_err());
575        assert!(WeightedAveraging::weighted_average(&[1.0, 2.0], &[-1.0, 1.0]).is_err());
576        assert!(WeightedAveraging::weighted_average(&[], &[]).is_err());
577        assert!(WeightedAveraging::weighted_average(&[1.0], &[1.0, 2.0]).is_err());
578    }
579
580    #[test]
581    fn aggregate_metrics_mean_and_weighted() {
582        let mean = MetricsAggregator::aggregate_metrics(&[2.0, 4.0, 6.0], None).unwrap();
583        assert!((mean - 4.0).abs() < 1e-12);
584
585        let weighted =
586            MetricsAggregator::aggregate_metrics(&[2.0, 4.0, 6.0], Some(&[1.0, 0.0, 0.0])).unwrap();
587        assert!((weighted - 2.0).abs() < 1e-12);
588    }
589
590    #[test]
591    fn aggregate_with_strategies() {
592        let v = [1.0, 2.0, 3.0, 4.0];
593        assert!(
594            (MetricsAggregator::aggregate_with(&v, AggregationStrategy::Median, None).unwrap()
595                - 2.5)
596                .abs()
597                < 1e-12
598        );
599        assert!(
600            (MetricsAggregator::aggregate_with(&v, AggregationStrategy::Min, None).unwrap() - 1.0)
601                .abs()
602                < 1e-12
603        );
604        assert!(
605            (MetricsAggregator::aggregate_with(&v, AggregationStrategy::Max, None).unwrap() - 4.0)
606                .abs()
607                < 1e-12
608        );
609        assert!(
610            MetricsAggregator::aggregate_with(&v, AggregationStrategy::Weighted, None).is_err()
611        );
612        assert!(MetricsAggregator::aggregate_with(&[], AggregationStrategy::Mean, None).is_err());
613    }
614
615    #[test]
616    fn median_odd_and_even() {
617        assert!((median(&[3.0, 1.0, 2.0]) - 2.0).abs() < 1e-12);
618        assert!((median(&[3.0, 1.0, 2.0, 4.0]) - 2.5).abs() < 1e-12);
619    }
620
621    #[test]
622    fn borda_count_hand_computed() {
623        // Three rankers over 3 items (0,1,2). Convention: position = rank, rank 0 best.
624        // r1: 0,1,2  -> scores 0:3, 1:2, 2:1
625        // r2: 1,0,2  -> scores 1:3, 0:2, 2:1
626        // r3: 0,2,1  -> scores 0:3, 2:2, 1:1
627        // Totals: 0: 3+2+3 = 8, 1: 2+3+1 = 6, 2: 1+1+2 = 4
628        // Consensus order by descending score: [0, 1, 2]
629        let rankings = vec![vec![0, 1, 2], vec![1, 0, 2], vec![0, 2, 1]];
630        let consensus = RankAggregation::borda_count(&rankings).unwrap();
631        assert_eq!(consensus, vec![0, 1, 2]);
632    }
633
634    #[test]
635    fn borda_count_tie_breaks_by_index() {
636        // Two rankers exactly reversed -> every item has identical Borda score.
637        // Deterministic tie-break by ascending index gives [0,1,2].
638        let rankings = vec![vec![0, 1, 2], vec![2, 1, 0]];
639        let consensus = RankAggregation::borda_count(&rankings).unwrap();
640        assert_eq!(consensus, vec![0, 1, 2]);
641    }
642
643    #[test]
644    fn borda_count_rejects_bad_input() {
645        assert!(RankAggregation::borda_count(&[]).is_err());
646        // Not a permutation (duplicate).
647        assert!(RankAggregation::borda_count(&[vec![0, 0, 1]]).is_err());
648        // Out of range.
649        assert!(RankAggregation::borda_count(&[vec![0, 1, 5]]).is_err());
650        // Mismatched lengths.
651        assert!(RankAggregation::borda_count(&[vec![0, 1, 2], vec![0, 1]]).is_err());
652    }
653
654    #[test]
655    fn kemeny_optimal_three_items_exact() {
656        // Classic example: rankings A>B>C, B>C>A, C>A>B form a Condorcet cycle.
657        // Items A=0,B=1,C=2.
658        // r1: 0,1,2 ; r2: 1,2,0 ; r3: 2,0,1
659        // Every ordering has the same total Kendall-tau cost (3), so the exact
660        // solver returns the lexicographically smallest optimum [0,1,2].
661        let rankings = vec![vec![0, 1, 2], vec![1, 2, 0], vec![2, 0, 1]];
662        let consensus = RankAggregation::kemeny_optimal(&rankings).unwrap();
663        assert_eq!(consensus, vec![0, 1, 2]);
664    }
665
666    #[test]
667    fn kemeny_optimal_clear_majority() {
668        // Strong majority for order 0 < 1 < 2.
669        // r1: 0,1,2 ; r2: 0,1,2 ; r3: 0,2,1
670        // Majority pairwise: 0<1 (3-0), 0<2 (3-0), 1<2 (2-1). Optimal = [0,1,2].
671        let rankings = vec![vec![0, 1, 2], vec![0, 1, 2], vec![0, 2, 1]];
672        let consensus = RankAggregation::kemeny_optimal(&rankings).unwrap();
673        assert_eq!(consensus, vec![0, 1, 2]);
674    }
675
676    #[test]
677    fn kemeny_optimal_recovers_unanimous() {
678        // Unanimous rankings: the optimum is exactly that ranking, cost 0.
679        let rankings = vec![vec![2, 0, 3, 1], vec![2, 0, 3, 1], vec![2, 0, 3, 1]];
680        let consensus = RankAggregation::kemeny_optimal(&rankings).unwrap();
681        assert_eq!(consensus, vec![2, 0, 3, 1]);
682        // Cost against its own preference matrix must be zero.
683        let prefer = pairwise_preference_matrix(&rankings, 4);
684        assert_eq!(kemeny_cost(&consensus, &prefer), 0);
685    }
686
687    #[test]
688    fn kemeny_local_search_large_n_unanimous() {
689        // n = 10 > exact limit: the local search must still recover a unanimous order.
690        let truth: Vec<usize> = vec![3, 7, 1, 9, 0, 5, 2, 8, 4, 6];
691        let rankings = vec![truth.clone(), truth.clone(), truth.clone()];
692        let consensus = RankAggregation::kemeny_optimal(&rankings).unwrap();
693        assert_eq!(consensus, truth);
694    }
695
696    #[test]
697    fn compute_consensus_identical_is_one() {
698        let rankings = vec![vec![0, 1, 2, 3], vec![0, 1, 2, 3]];
699        let c = ConsensusMetrics::compute_consensus(&rankings).unwrap();
700        assert!((c - 1.0).abs() < 1e-12);
701    }
702
703    #[test]
704    fn compute_consensus_reversed_is_minus_one() {
705        let rankings = vec![vec![0, 1, 2, 3], vec![3, 2, 1, 0]];
706        let c = ConsensusMetrics::compute_consensus(&rankings).unwrap();
707        assert!((c + 1.0).abs() < 1e-12);
708    }
709
710    #[test]
711    fn compute_consensus_single_ranking() {
712        let c = ConsensusMetrics::compute_consensus(&[vec![0, 1, 2]]).unwrap();
713        assert!((c - 1.0).abs() < 1e-12);
714    }
715
716    #[test]
717    fn compute_consensus_one_swap() {
718        // Two rankings differing by exactly one adjacent swap over 4 items.
719        // n choose 2 = 6 pairs; 5 concordant, 1 discordant -> tau = 4/6 = 0.6667.
720        let rankings = vec![vec![0, 1, 2, 3], vec![1, 0, 2, 3]];
721        let c = ConsensusMetrics::compute_consensus(&rankings).unwrap();
722        assert!((c - (4.0 / 6.0)).abs() < 1e-12);
723    }
724
725    #[test]
726    fn topsis_prefers_dominant_alternative() {
727        // Two criteria, three alternatives. Alternative 0 dominates on both.
728        // criterion 0 scores: [10, 5, 1]; criterion 1 scores: [10, 6, 2]
729        let criteria = vec![vec![10.0, 5.0, 1.0], vec![10.0, 6.0, 2.0]];
730        let weights = vec![0.5, 0.5];
731        let scores = MultiCriteriaEvaluation::evaluate_multi_criteria(&criteria, &weights).unwrap();
732        assert_eq!(scores.len(), 3);
733        // The dominant alternative is the positive ideal -> closeness 1.0.
734        assert!((scores[0] - 1.0).abs() < 1e-9);
735        // The dominated alternative is the negative ideal -> closeness 0.0.
736        assert!(scores[2].abs() < 1e-9);
737        // Ordering is strictly decreasing.
738        assert!(scores[0] > scores[1] && scores[1] > scores[2]);
739    }
740
741    #[test]
742    fn topsis_validates_inputs() {
743        assert!(MultiCriteriaEvaluation::evaluate_multi_criteria(&[], &[]).is_err());
744        // Mismatched weight count.
745        assert!(
746            MultiCriteriaEvaluation::evaluate_multi_criteria(&[vec![1.0, 2.0]], &[1.0, 1.0])
747                .is_err()
748        );
749        // Inconsistent criterion lengths.
750        assert!(MultiCriteriaEvaluation::evaluate_multi_criteria(
751            &[vec![1.0, 2.0], vec![1.0]],
752            &[1.0, 1.0]
753        )
754        .is_err());
755        // Zero weights.
756        assert!(
757            MultiCriteriaEvaluation::evaluate_multi_criteria(&[vec![1.0, 2.0]], &[0.0]).is_err()
758        );
759    }
760}