likwid/backend/src/voting/ranked_choice.rs
Marco Allegretti d4bcba405b backend: modify 56 files
Verified changes:
- modify backend/src/api/analytics.rs
- modify backend/src/api/approvals.rs
- modify backend/src/api/auth.rs
- modify backend/src/api/comments.rs
- modify backend/src/api/communities.rs
- modify backend/src/api/conflicts.rs
- modify backend/src/api/delegation.rs
- modify backend/src/api/deliberation.rs
- modify backend/src/api/demo.rs
- modify backend/src/api/exports.rs
- modify backend/src/api/federation.rs
- modify backend/src/api/gitlab.rs
- modify backend/src/api/invitations.rs
- modify backend/src/api/lifecycle.rs
- modify backend/src/api/mod.rs
- modify backend/src/api/moderation.rs
- modify backend/src/api/moderation_ledger.rs
- modify backend/src/api/notifications.rs
- modify backend/src/api/permissions.rs
- modify backend/src/api/plugins.rs
- modify backend/src/api/proposals.rs
- modify backend/src/api/roles.rs
- modify backend/src/api/self_moderation.rs
- modify backend/src/api/settings.rs
- modify backend/src/api/users.rs
- modify backend/src/api/voting_config.rs
- modify backend/src/api/workflows.rs
- modify backend/src/auth/jwt.rs
- modify backend/src/auth/middleware.rs
- modify backend/src/auth/mod.rs
- modify backend/src/demo/mod.rs
- modify backend/src/main.rs
- modify backend/src/models/community.rs
- modify backend/src/models/mod.rs
- modify backend/src/models/proposal.rs
- modify backend/src/models/user.rs
- modify backend/src/plugins/builtin/conflict_resolution.rs
- modify backend/src/plugins/builtin/decision_workflows.rs
- modify backend/src/plugins/builtin/federation.rs
- modify backend/src/plugins/builtin/governance_analytics.rs
- modify backend/src/plugins/builtin/moderation_ledger.rs
- modify backend/src/plugins/builtin/proposal_lifecycle.rs
- modify backend/src/plugins/builtin/public_data_export.rs
- modify backend/src/plugins/builtin/self_moderation.rs
- modify backend/src/plugins/builtin/structured_deliberation.rs
- modify backend/src/plugins/hooks.rs
- modify backend/src/plugins/manager.rs
- modify backend/src/plugins/wasm/host_api.rs
- modify backend/src/plugins/wasm/plugin.rs
- modify backend/src/plugins/wasm/runtime.rs
- modify backend/src/rate_limit.rs
- modify backend/src/voting/mod.rs
- modify backend/src/voting/quadratic.rs
- modify backend/src/voting/ranked_choice.rs
- modify backend/src/voting/schulze.rs
- modify backend/src/voting/star.rs

Diffstat:
- 56 files changed, 2697 insertions(+), 1629 deletions(-)
2026-02-03 17:54:39 +01:00

271 lines
8.2 KiB
Rust

//! Ranked Choice / Instant Runoff Voting Implementation
//!
//! Voters rank options in order of preference. If no option has a majority,
//! the lowest-ranked option is eliminated and votes are redistributed
//! until one option achieves majority.
use std::collections::{HashMap, HashSet};
use uuid::Uuid;
use super::{RankedOption, RoundResult, VotingDetails, VotingResult};
/// A ranked ballot (voter's preference order)
#[derive(Debug, Clone)]
pub struct RankedBallot {
pub rankings: Vec<Uuid>, // Ordered list: index 0 = first choice
}
/// Calculate voting results using Ranked Choice / Instant Runoff
pub fn calculate(options: &[Uuid], ballots: &[RankedBallot]) -> VotingResult {
if options.is_empty() || ballots.is_empty() {
return VotingResult {
winner: None,
ranking: vec![],
details: VotingDetails::RankedChoice {
rounds: vec![],
eliminated: vec![],
},
total_ballots: ballots.len(),
};
}
let mut active_options: HashSet<Uuid> = options.iter().cloned().collect();
let mut eliminated: Vec<Uuid> = vec![];
let mut rounds: Vec<RoundResult> = vec![];
let majority_threshold = (ballots.len() as f64 / 2.0).floor() as i64 + 1;
loop {
// Count first-choice votes among active options
let mut vote_counts: HashMap<Uuid, i64> =
active_options.iter().map(|&id| (id, 0)).collect();
for ballot in ballots {
// Find first choice among active options
for opt in &ballot.rankings {
if active_options.contains(opt) {
if let Some(c) = vote_counts.get_mut(opt) {
*c += 1;
}
break;
}
}
}
// Sort by vote count
let mut sorted: Vec<(Uuid, i64)> = vote_counts
.iter()
.map(|(&id, &count)| (id, count))
.collect();
sorted.sort_by(|a, b| b.1.cmp(&a.1));
let round_num = rounds.len() + 1;
// Check for majority winner
if let Some((winner, count)) = sorted.first() {
if *count >= majority_threshold {
rounds.push(RoundResult {
round: round_num,
vote_counts: sorted.clone(),
eliminated: None,
});
// Build final ranking
let mut final_ranking: Vec<RankedOption> = sorted
.iter()
.enumerate()
.map(|(i, (id, count))| RankedOption {
option_id: *id,
rank: i + 1,
score: *count as f64,
})
.collect();
// Add eliminated options at the end (in reverse elimination order)
for &opt in eliminated.iter().rev() {
final_ranking.push(RankedOption {
option_id: opt,
rank: final_ranking.len() + 1,
score: 0.0,
});
}
return VotingResult {
winner: Some(*winner),
ranking: final_ranking,
details: VotingDetails::RankedChoice { rounds, eliminated },
total_ballots: ballots.len(),
};
}
}
// Only one option left - it wins
if active_options.len() <= 1 {
let winner = active_options.iter().next().cloned();
rounds.push(RoundResult {
round: round_num,
vote_counts: sorted.clone(),
eliminated: None,
});
let mut final_ranking: Vec<RankedOption> = sorted
.iter()
.enumerate()
.map(|(i, (id, count))| RankedOption {
option_id: *id,
rank: i + 1,
score: *count as f64,
})
.collect();
for &opt in eliminated.iter().rev() {
final_ranking.push(RankedOption {
option_id: opt,
rank: final_ranking.len() + 1,
score: 0.0,
});
}
return VotingResult {
winner,
ranking: final_ranking,
details: VotingDetails::RankedChoice { rounds, eliminated },
total_ballots: ballots.len(),
};
}
// Eliminate lowest-ranked option
if let Some((loser, _)) = sorted.last() {
let loser_id = *loser;
rounds.push(RoundResult {
round: round_num,
vote_counts: sorted,
eliminated: Some(loser_id),
});
active_options.remove(&loser_id);
eliminated.push(loser_id);
} else {
break;
}
}
// Fallback (shouldn't reach here)
VotingResult {
winner: None,
ranking: vec![],
details: VotingDetails::RankedChoice { rounds, eliminated },
total_ballots: ballots.len(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_first_round_majority() {
let a = Uuid::new_v4();
let b = Uuid::new_v4();
let c = Uuid::new_v4();
let options = vec![a, b, c];
// A has clear majority
let ballots = vec![
RankedBallot {
rankings: vec![a, b, c],
},
RankedBallot {
rankings: vec![a, b, c],
},
RankedBallot {
rankings: vec![a, c, b],
},
RankedBallot {
rankings: vec![b, a, c],
},
RankedBallot {
rankings: vec![c, b, a],
},
];
let result = calculate(&options, &ballots);
assert_eq!(result.winner, Some(a)); // 3/5 = 60% majority
}
#[test]
fn test_runoff_needed() {
let a = Uuid::new_v4();
let b = Uuid::new_v4();
let c = Uuid::new_v4();
let options = vec![a, b, c];
// No first-round majority, C eliminated, B wins
let ballots = vec![
RankedBallot {
rankings: vec![a, b, c],
},
RankedBallot {
rankings: vec![a, b, c],
},
RankedBallot {
rankings: vec![b, a, c],
},
RankedBallot {
rankings: vec![b, c, a],
},
RankedBallot {
rankings: vec![c, b, a],
}, // C's vote goes to B
];
let result = calculate(&options, &ballots);
// Round 1: A=2, B=2, C=1 -> C eliminated
// Round 2: A=2, B=3 -> B wins with majority
assert_eq!(result.winner, Some(b));
if let VotingDetails::RankedChoice { rounds, eliminated } = &result.details {
assert_eq!(rounds.len(), 2);
assert_eq!(eliminated, &vec![c]);
} else {
panic!("Wrong voting details type");
}
}
#[test]
fn test_spoiler_effect_eliminated() {
// Classic example: spoiler candidate doesn't affect outcome
let a = Uuid::new_v4();
let b = Uuid::new_v4();
let spoiler = Uuid::new_v4();
let options = vec![a, b, spoiler];
let ballots = vec![
RankedBallot {
rankings: vec![a, spoiler, b],
},
RankedBallot {
rankings: vec![a, spoiler, b],
},
RankedBallot {
rankings: vec![spoiler, a, b],
}, // Spoiler fans prefer A
RankedBallot {
rankings: vec![b, a, spoiler],
},
RankedBallot {
rankings: vec![b, a, spoiler],
},
];
let result = calculate(&options, &ballots);
// Without RCV, spoiler might split A's vote
// With RCV, spoiler eliminated, vote goes to A
// Round 1: A=2, B=2, Spoiler=1 -> Spoiler eliminated
// Round 2: A=3, B=2 -> A wins
assert_eq!(result.winner, Some(a));
}
}