Skip to main content

Crate vote

Crate vote 

Source
Expand description

build Crates.io docs.rs

§About

vote aggregates ranked preferences with plurality, Borda, Copeland, instant-runoff voting, and random dictatorship. It also provides pairwise comparisons and strict Condorcet winner detection. It validates complete ballots, reports scores and ties explicitly, and supports repeatable experiments.

§Install

Requires Rust 1.85 or newer. Add vote to your project’s Cargo.toml:

[dependencies]
vote = "0.3"

§Usage

use vote::{borda, plurality, Preference};

fn main() -> Result<(), vote::PreferenceError> {
    let preferences = Preference::new(vec![
        vec!["bridge", "canopy", "courtyard"],
        vec!["canopy", "bridge", "courtyard"],
        vec!["bridge", "courtyard", "canopy"],
    ])?;

    // Reuse the same validated profile with multiple voting methods.
    let first_choices = plurality(&preferences);
    let ranked_scores = borda(&preferences);
    assert_eq!(first_choices.winners(), &["bridge"]);
    assert_eq!(ranked_scores.groups()[0].score, 5);

    for group in ranked_scores.groups() {
        println!("{} points: {:?}", group.score, group.candidates);
    }
    Ok(())
}

Output:

5 points: ["bridge"]
3 points: ["canopy"]
1 points: ["courtyard"]

§Voting Methods

MethodRuleOutput
pluralityOne point for each voter’s first choiceScore groups, highest first
bordaWith m candidates, award m - 1, ..., 0 pointsScore groups, highest first
random_dictatorSelect a voter uniformlyA copy of that voter’s ranked ballot
random_dictator_with_rngSame rule, using the caller’s RNGA copy of that voter’s ranked ballot
copelandTwo points per pairwise win, one per tie, zero per lossScore groups, highest first
condorcet_winnerFind a candidate who strictly defeats every opponent head-to-headSome(candidate) or None
pairwiseCount voters preferring each candidate to each opponentLabeled comparison matrix
instant_runoffTransfer votes from one lowest candidate at a time until a strict majorityWinner or unresolved elimination tie, plus round trace
instant_runoff_bySame count with a caller-supplied elimination tie-break ruleWinner or unresolved elimination tie, plus round trace

Each ballot represents one voter. Repeated identical ballots retain their multiplicity. Scores use u128, and candidates with zero points remain in the results. All methods use the same validated complete ballots. Version 0.3 preserves the existing 0.2 API. See cargo run --example compare_methods for a full comparison.

§Pairwise voting and Copeland

pairwise(&preferences) returns candidates in first-ballot order and a square matrix: entry [i][j] counts voters who prefer candidate i to candidate j. Opposite off-diagonal counts sum to the voter count; diagonal entries are zero. preference_count(&candidate, &opponent) provides lookup by candidate identity, returning None for an unknown candidate.

Copeland uses the common half-point tie convention, scaled by two to preserve integer scores: win = 2, tie = 1, loss = 0. Equal final scores remain tied in Results. A strict Condorcet winner always uniquely wins Copeland, but Copeland can also return a unique winner when there is no strict Condorcet winner. condorcet_winner returns None for cycles or other profiles without a candidate who strictly beats every opponent. A sole candidate wins vacuously.

use vote::{copeland, pairwise, Preference};

fn main() -> Result<(), vote::PreferenceError> {
    // A three-way cycle: each candidate defeats one opponent and loses to another.
    let preferences = Preference::new(vec![
        vec!["A", "B", "C"],
        vec!["B", "C", "A"],
        vec!["C", "A", "B"],
    ])?;
    let comparisons = pairwise(&preferences);
    assert_eq!(comparisons.preference_count(&"A", &"B"), Some(2));
    assert_eq!(comparisons.condorcet_winner(), None);
    assert_eq!(comparisons.copeland(), copeland(&preferences));
    assert_eq!(comparisons.copeland().winners(), &["A", "B", "C"]);
    Ok(())
}

Computing the matrix takes expected O(voters * candidates²) time and O(candidates²) auxiliary space. Reuse its copeland() and condorcet_winner() methods to compare outcomes without recounting ballots.

§Instant-runoff voting

Each round counts every voter’s highest-ranked active candidate. A strict majority, more than half of all ballots, elects the winner immediately. Otherwise, one candidate with the fewest votes is eliminated and their ballots transfer to the next active preference. Complete ballots do not become exhausted.

The default instant_runoff stops at any elimination tie, including zero-vote ties. RunoffOutcome::EliminationTie identifies candidates tied for elimination; it does not declare them winners. The round tally’s winners() are only that round’s leaders. Use the runoff result’s winner() for the elected candidate. rounds() exposes all tallies and eliminations, including the terminal round; it does not assign an overall ranking to the unelected candidates.

With instant_runoff_by, Ordering::Less favors the first candidate, matching Results::ranking_by. Of candidates tied for the lowest count, the greatest (least preferred) is eliminated. The comparator never overrides different vote counts. If it leaves several candidates equally least preferred, the count still stops with an explicit elimination tie. Only one candidate is removed per round.

use vote::{instant_runoff, instant_runoff_by, Preference, RunoffOutcome};

fn main() -> Result<(), vote::PreferenceError> {
    let preferences = Preference::new(vec![vec!["B", "A"], vec!["A", "B"]])?;
    let unresolved = instant_runoff(&preferences);
    assert_eq!(unresolved.winner(), None);
    assert_eq!(
        unresolved.outcome(),
        &RunoffOutcome::EliminationTie(vec!["B", "A"]),
    );

    // Favor alphabetically earlier names; eliminate B in the tied first round.
    let resolved = instant_runoff_by(&preferences, Ord::cmp);
    assert_eq!(resolved.rounds()[0].eliminated, Some("B"));
    assert_eq!(resolved.winner(), Some(&"A"));
    Ok(())
}

The counting rule follows the standard preferential transfer procedure described by the Australian Electoral Commission. Tie resolution is explicitly caller-controlled; historical-count or jurisdiction-specific tie rules are not applied automatically. The Copeland scoring convention is Copeland(0.5).

§Ballot requirements

Every ballot lists the same candidates exactly once, from most to least preferred. Profiles and ballots must be nonempty. Partial rankings, tied ranks within ballots, and separate voter weights are not supported. Candidate types need Eq + Hash for validation and Clone for voting; Ord is optional.

Preference::new (or Preference::try_from) returns a PreferenceError for invalid input, with zero-based ballot and rank indices where applicable:

use vote::{Preference, PreferenceError};

let malformed = Preference::new(vec![vec![0, 1], vec![0, 1, 1]]);
assert_eq!(
    malformed,
    Err(PreferenceError::BallotLengthMismatch {
        ballot: 1,
        expected: 2,
        actual: 3,
    }),
);

Validated ballots are accessible through ballots() and cannot be mutated in place. Validation compares each ballot with the first candidate set, taking expected time proportional to the number of ballot entries.

§Ties

Results::groups() preserves equal scores in a shared ScoreGroup. winners() returns every candidate tied for the highest score. Candidates within each group are displayed in first-ballot order; that order does not break the tie. Reordering voters can change this display order, but not scores or tie membership.

If an application needs a single ranking, supply its tie-break rule explicitly:

use vote::{plurality, Preference};

fn main() -> Result<(), vote::PreferenceError> {
    let preferences = Preference::new(vec![vec!["B", "A"], vec!["A", "B"]])?;
    let result = plurality(&preferences);

    assert_eq!(result.winners(), &["B", "A"]); // Still tied.
    let alphabetical = result.ranking_by(Ord::cmp);
    assert_eq!(alphabetical, vec!["A", "B"]);
    Ok(())
}

The comparator only orders candidates with equal scores. Use a total order to resolve all ties. The original score groups remain unchanged.

§Seeded experiments

Add rand = "0.10.2" to your project’s dependencies to run this example:

use rand::{rngs::StdRng, SeedableRng};
use vote::{random_dictator_with_rng, Preference};

fn main() -> Result<(), vote::PreferenceError> {
    let preferences = Preference::new(vec![vec!["A", "B"], vec!["B", "A"]])?;
    let mut first_run = StdRng::seed_from_u64(42);
    let mut repeated_run = StdRng::seed_from_u64(42);

    for _ in 0..10 {
        assert_eq!(
            random_dictator_with_rng(&preferences, &mut first_run),
            random_dictator_with_rng(&preferences, &mut repeated_run),
        );
    }
    Ok(())
}

To reproduce an experiment, preserve the seed, RNG implementation, dependency versions, target, and ballot order. StdRng does not guarantee the same stream across versions or platforms. vote enables rand’s unbiased integer sampling.

§Migrating from 0.1

Version 0.2 makes intentional API changes:

Earlier API0.2 API
Preference(ballots) followed by audit()Preference::new(ballots)?, which validates once
Public preferences.0preferences.ballots() or preferences.into_ballots()
plurality(preferences) or borda(preferences)Borrow with plurality(&preferences) or borda(&preferences)
Flat results.0 ranking with implicit tie orderresults.groups(), results.winners(), or an explicit results.ranking_by(...)
random_dictator(preferences).0random_dictator(&preferences), returning Vec<T> directly

Borda now reports standard m - 1, ..., 0 scores. The earlier implementation used m, ..., 1 internally; rankings and ties are unchanged for valid complete ballots. Invalid inputs produce construction errors instead of audit/indexing panics.

§Development

cargo test --all-features
cargo fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warnings
RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features
cargo run --example simple
cargo run --example seeded
cargo run --example compare_methods

The README is also the crate-level documentation, so its Rust examples run as documentation tests. CI checks stable Rust on Linux, macOS, and Windows, plus Rust 1.85 on Linux. Tests include the malformed-ballot regressions and all 1,554 complete profiles with three candidates and one to four voters. Pairwise and runoff tests check that same exhaustive set, including independent reference counts and full runoff traces, plus a published AEC transfer example.

See the published API documentation, examples, and changelog. For API documentation matching this checkout, run cargo doc --open.

§License

Licensed under either the MIT license or the Apache License, Version 2.0, at your option.

Structs§

PairwiseResults
Head-to-head preference counts, with candidates in first-ballot order.
Preference
A nonempty profile of complete, strict ranked ballots over the same candidates.
Results
Voting scores, grouped from highest to lowest.
RunoffResults
An instant-runoff outcome and the full trace of rounds leading to it.
RunoffRound
A tally before an elimination, or the terminal tally of a runoff count.
ScoreGroup
All candidates with a particular score. Their order does not imply a ranking.

Enums§

PreferenceError
Why a profile could not be constructed. All ballot and rank indices are zero-based.
RunoffOutcome
The outcome of an instant-runoff count.

Functions§

borda
Sum Borda points: with m candidates, each ballot awards m - 1, ..., 0.
condorcet_winner
Copy the strict Condorcet winner, if one candidate beats every other head-to-head.
copeland
Score Copeland(0.5): two points per pairwise win, one per tie, zero per loss.
instant_runoff
Run instant-runoff voting, stopping if the lowest vote count is tied.
instant_runoff_by
Run instant-runoff voting with an explicit preference order for elimination ties.
pairwise
Count every pairwise preference in a validated profile.
plurality
Count first-place votes, retaining every candidate, including those with zero votes.
random_dictator
Select a voter uniformly and return a copy of that voter’s ranked ballot.
random_dictator_with_rng
Select a voter uniformly using the caller’s RNG and copy their ranked ballot.