//! Permutation-consensus for listwise judging (PCFJudge, arXiv:2603.20562).
//!
//! When a judge ranks more than two candidates at once, its verdict is swayed
//! by the order the candidates were presented in, the same position bias the
//! pairwise [`apply_position_swap`](super::bias::apply_position_swap) cancels
//! for two responses. The fix that generalizes to a list: present the
//! candidates in several different orders, judge each ordering, then aggregate
//! the rankings into a consensus that no single presentation order dictated.
//!
//! This module is the transport-free aggregation half. The runner judges each
//! permutation live and maps every result back into the original candidate-id
//! space; [`consensus_ranking`] then folds those per-permutation rankings into
//! a single order via Borda count and reports how much they disagreed. A
//! [`ConsensusRanking::disagreement`] near zero means the judge ranked the same
//! way regardless of order (low position bias); a high value flags a judge
//! whose listwise verdict is presentation-driven.

/// The consensus of several per-permutation rankings of the same candidates.
#[derive(Debug, Clone, PartialEq)]
pub struct ConsensusRanking {
    /// Candidate ids best-first, by descending Borda score. Ties break toward
    /// the lower candidate id so the order is deterministic.
    pub order: Vec<usize>,
    /// How much the input rankings disagreed, in `0.0..=1.0`. The mean, over
    /// candidates, of each candidate's position spread (max rank minus min rank
    /// across the permutations) normalized by the largest possible spread.
    /// `0.0` when every permutation produced the same ranking.
    pub disagreement: f64,
}

/// Fold per-permutation rankings of the same candidate set into one consensus
/// ranking via Borda count.
///
/// Each entry of `rankings` is one judged ordering, candidate ids best-first,
/// and every entry must be a permutation of the same `0..n` id set (the runner
/// guarantees this by re-mapping each permutation's result back to the original
/// ids). A candidate in position `p` of an `n`-candidate ranking earns `n-1-p`
/// Borda points; the consensus orders candidates by total points, descending.
/// Returns an empty ranking for empty input or rankings of fewer than two
/// candidates (nothing to aggregate).
pub fn consensus_ranking(rankings: &[Vec<usize>]) -> ConsensusRanking {
    let n = rankings.first().map(Vec::len).unwrap_or(0);
    if n < 2 || rankings.iter().any(|r| r.len() != n) {
        let order = rankings.first().cloned().unwrap_or_default();
        return ConsensusRanking {
            order,
            disagreement: 0.0,
        };
    }

    // Borda points and observed positions, per candidate id.
    let mut points = vec![0usize; n];
    let mut min_pos = vec![usize::MAX; n];
    let mut max_pos = vec![0usize; n];
    for ranking in rankings {
        for (pos, &cand) in ranking.iter().enumerate() {
            // A malformed id (out of range or duplicated set) would corrupt the
            // tally; the runner supplies clean permutations, but guard anyway.
            if cand >= n {
                continue;
            }
            points[cand] += n - 1 - pos;
            min_pos[cand] = min_pos[cand].min(pos);
            max_pos[cand] = max_pos[cand].max(pos);
        }
    }

    // Order by Borda points descending, ties toward the lower id.
    let mut order: Vec<usize> = (0..n).collect();
    order.sort_by(|&a, &b| points[b].cmp(&points[a]).then(a.cmp(&b)));

    // Disagreement: mean normalized position spread across candidates.
    let max_spread = (n - 1) as f64;
    let spread_sum: f64 = (0..n)
        .map(|c| {
            if min_pos[c] == usize::MAX {
                0.0
            } else {
                (max_pos[c] - min_pos[c]) as f64 / max_spread
            }
        })
        .sum();
    ConsensusRanking {
        order,
        disagreement: spread_sum / n as f64,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn unanimous_rankings_pass_through_with_zero_disagreement() {
        let rankings = vec![vec![2, 0, 1], vec![2, 0, 1], vec![2, 0, 1]];
        let consensus = consensus_ranking(&rankings);
        assert_eq!(consensus.order, vec![2, 0, 1]);
        assert_eq!(consensus.disagreement, 0.0);
    }

    #[test]
    fn borda_count_picks_the_consensus_winner() {
        // Candidate 1 is first in two of three rankings; Borda favors it even
        // though ranking C put it last.
        let rankings = vec![vec![1, 0, 2], vec![1, 2, 0], vec![0, 2, 1]];
        let consensus = consensus_ranking(&rankings);
        assert_eq!(consensus.order[0], 1, "Borda winner is candidate 1");
    }

    #[test]
    fn position_bias_cancels_across_reversed_permutations() {
        // A judge that always ranks by presentation order (best = whatever it
        // saw first) gives opposite rankings on a permutation and its reverse;
        // the consensus is a three-way tie, broken deterministically by id.
        let rankings = vec![vec![0, 1, 2], vec![2, 1, 0]];
        let consensus = consensus_ranking(&rankings);
        // Every candidate has equal Borda points, so ties resolve by id order.
        assert_eq!(consensus.order, vec![0, 1, 2]);
        // The two end candidates swap ends (spread 2/2 = 1.0 each) while the
        // middle one stays put (spread 0), so the mean disagreement is 2/3.
        assert!((consensus.disagreement - 2.0 / 3.0).abs() < 1e-9);
    }

    #[test]
    fn a_full_reverse_of_a_pair_is_total_disagreement() {
        // With no fixed pivot, a reversed pair drifts to the maximum.
        let consensus = consensus_ranking(&[vec![0, 1], vec![1, 0]]);
        assert_eq!(consensus.disagreement, 1.0);
    }

    #[test]
    fn a_single_ranking_is_its_own_consensus() {
        let consensus = consensus_ranking(&[vec![1, 0, 2]]);
        assert_eq!(consensus.order, vec![1, 0, 2]);
        assert_eq!(consensus.disagreement, 0.0);
    }

    #[test]
    fn empty_or_degenerate_input_is_an_empty_consensus() {
        assert_eq!(consensus_ranking(&[]).order, Vec::<usize>::new());
        // A one-candidate "ranking" has nothing to aggregate.
        assert_eq!(consensus_ranking(&[vec![0]]).order, vec![0]);
    }

    #[test]
    fn mismatched_ranking_lengths_do_not_panic() {
        // Defensive: a runner bug that produced ragged rankings degrades to the
        // first ranking rather than corrupting the tally or panicking.
        let consensus = consensus_ranking(&[vec![0, 1, 2], vec![0, 1]]);
        assert_eq!(consensus.order, vec![0, 1, 2]);
    }
}
