voting-mcp 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
voting_mcp/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """voting-mcp: principled social-choice aggregation rules as MCP tools."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,54 @@
1
+ """Dispatch: map a rule name to its implementation and apply it to a profile."""
2
+
3
+ from collections.abc import Callable
4
+ from enum import StrEnum
5
+
6
+ from voting_mcp.rules import (
7
+ approval,
8
+ borda,
9
+ condorcet,
10
+ copeland,
11
+ majority,
12
+ opinion_pool,
13
+ plurality,
14
+ stv,
15
+ )
16
+ from voting_mcp.types import Profile, Result, TieBreak
17
+
18
+
19
+ class RuleName(StrEnum):
20
+ """Canonical names of the aggregation rules (used as the tool enum)."""
21
+
22
+ BORDA = "borda"
23
+ COPELAND = "copeland"
24
+ CONDORCET = "condorcet"
25
+ APPROVAL = "approval"
26
+ STV = "stv"
27
+ OPINION_POOL = "opinion_pool"
28
+ PLURALITY = "plurality"
29
+ MAJORITY = "majority"
30
+
31
+
32
+ _RuleFn = Callable[..., Result]
33
+
34
+ _DISPATCH: dict[RuleName, _RuleFn] = {
35
+ RuleName.BORDA: borda.borda,
36
+ RuleName.COPELAND: copeland.copeland,
37
+ RuleName.CONDORCET: condorcet.condorcet,
38
+ RuleName.APPROVAL: approval.approval,
39
+ RuleName.STV: stv.stv,
40
+ RuleName.OPINION_POOL: opinion_pool.opinion_pool,
41
+ RuleName.PLURALITY: plurality.plurality,
42
+ RuleName.MAJORITY: majority.majority,
43
+ }
44
+
45
+
46
+ def aggregate(
47
+ profile: Profile,
48
+ rule: RuleName,
49
+ *,
50
+ tie_break: TieBreak = TieBreak.LEXICOGRAPHIC,
51
+ seed: int = 0,
52
+ ) -> Result:
53
+ """Apply ``rule`` to ``profile``. Raises ``ValueError`` on a ballot-kind mismatch."""
54
+ return _DISPATCH[rule](profile, tie_break=tie_break, seed=seed)
@@ -0,0 +1,23 @@
1
+ """Social-choice rules. Each module exposes one pure ``(Profile) -> Result``."""
2
+
3
+ from voting_mcp.rules import (
4
+ approval,
5
+ borda,
6
+ condorcet,
7
+ copeland,
8
+ majority,
9
+ opinion_pool,
10
+ plurality,
11
+ stv,
12
+ )
13
+
14
+ __all__ = [
15
+ "approval",
16
+ "borda",
17
+ "condorcet",
18
+ "copeland",
19
+ "majority",
20
+ "opinion_pool",
21
+ "plurality",
22
+ "stv",
23
+ ]
@@ -0,0 +1,69 @@
1
+ """Shared ballot-extraction and pairwise helpers for the rules.
2
+
3
+ Each extractor enforces that the profile carries the ballot kind a rule needs,
4
+ raising a clear ``ValueError`` rather than silently coercing a mismatched kind.
5
+ """
6
+
7
+ from voting_mcp.types import (
8
+ ApprovalBallot,
9
+ DistributionBallot,
10
+ Profile,
11
+ RankingBallot,
12
+ )
13
+
14
+
15
+ def rankings(profile: Profile, rule: str) -> list[tuple[list[str], float]]:
16
+ """Return ``(ranking, weight)`` pairs; raise if any ballot is not a ranking."""
17
+ out: list[tuple[list[str], float]] = []
18
+ for ballot in profile.ballots:
19
+ if not isinstance(ballot, RankingBallot):
20
+ raise ValueError(f"{rule} requires ranking ballots; got a {ballot.kind} ballot")
21
+ out.append((ballot.ranking, ballot.weight))
22
+ return out
23
+
24
+
25
+ def approvals(profile: Profile, rule: str) -> list[tuple[list[str], float]]:
26
+ """Return ``(approved, weight)`` pairs; raise if any ballot is not approval."""
27
+ out: list[tuple[list[str], float]] = []
28
+ for ballot in profile.ballots:
29
+ if not isinstance(ballot, ApprovalBallot):
30
+ raise ValueError(f"{rule} requires approval ballots; got a {ballot.kind} ballot")
31
+ out.append((ballot.approved, ballot.weight))
32
+ return out
33
+
34
+
35
+ def distributions(profile: Profile, rule: str) -> list[tuple[dict[str, float], float]]:
36
+ """Return ``(distribution, weight)`` pairs; raise if any ballot is not a dist."""
37
+ out: list[tuple[dict[str, float], float]] = []
38
+ for ballot in profile.ballots:
39
+ if not isinstance(ballot, DistributionBallot):
40
+ raise ValueError(f"{rule} requires distribution ballots; got a {ballot.kind} ballot")
41
+ out.append((ballot.distribution, ballot.weight))
42
+ return out
43
+
44
+
45
+ def pairwise_tallies(profile: Profile, rule: str) -> dict[tuple[str, str], float]:
46
+ """Weighted pairwise preference tallies.
47
+
48
+ ``tally[(x, y)]`` is the total ballot weight that ranks ``x`` above ``y``.
49
+ A candidate ranked on a (possibly truncated) ballot is considered preferred
50
+ to every candidate absent from that ballot; if both are absent the ballot
51
+ abstains on that pair.
52
+ """
53
+ cands = profile.candidates
54
+ tally: dict[tuple[str, str], float] = {
55
+ (x, y): 0.0 for x in cands for y in cands if x != y
56
+ }
57
+ for ranking, weight in rankings(profile, rule):
58
+ pos = {c: i for i, c in enumerate(ranking)}
59
+ for x in cands:
60
+ for y in cands:
61
+ if x == y:
62
+ continue
63
+ x_in, y_in = x in pos, y in pos
64
+ if x_in and y_in:
65
+ if pos[x] < pos[y]:
66
+ tally[(x, y)] += weight
67
+ elif x_in and not y_in:
68
+ tally[(x, y)] += weight
69
+ return tally
@@ -0,0 +1,19 @@
1
+ """Approval: each candidate scores the total weight of ballots approving it."""
2
+
3
+ from voting_mcp.rules._common import approvals
4
+ from voting_mcp.scoring import result_from_scores
5
+ from voting_mcp.types import Profile, Result, TieBreak
6
+
7
+
8
+ def approval(
9
+ profile: Profile,
10
+ *,
11
+ tie_break: TieBreak = TieBreak.LEXICOGRAPHIC,
12
+ seed: int = 0,
13
+ ) -> Result:
14
+ """Most-approved candidate wins. An empty approval set simply adds nothing."""
15
+ scores = dict.fromkeys(profile.candidates, 0.0)
16
+ for approved, weight in approvals(profile, "approval"):
17
+ for cand in approved:
18
+ scores[cand] += weight
19
+ return result_from_scores("approval", scores, tie_break=tie_break, seed=seed)
@@ -0,0 +1,35 @@
1
+ """Borda count: positional scoring. Condorcet-inconsistent and clone-sensitive.
2
+
3
+ With ``m`` candidates a ranked candidate at position ``i`` (0 = top) earns
4
+ ``m - 1 - i`` points. A truncated ballot ranks only ``k`` candidates: the
5
+ unranked ones split the remaining low positions equally, each receiving the
6
+ average ``(m - k - 1) / 2`` points rather than being coerced to zero.
7
+ """
8
+
9
+ from voting_mcp.rules._common import rankings
10
+ from voting_mcp.scoring import result_from_scores
11
+ from voting_mcp.types import Profile, Result, TieBreak
12
+
13
+
14
+ def borda(
15
+ profile: Profile,
16
+ *,
17
+ tie_break: TieBreak = TieBreak.LEXICOGRAPHIC,
18
+ seed: int = 0,
19
+ ) -> Result:
20
+ """Sum positional Borda points per candidate; highest total wins."""
21
+ m = len(profile.candidates)
22
+ scores = dict.fromkeys(profile.candidates, 0.0)
23
+
24
+ for ranking, weight in rankings(profile, "borda"):
25
+ k = len(ranking)
26
+ for i, cand in enumerate(ranking):
27
+ scores[cand] += weight * (m - 1 - i)
28
+ if k < m:
29
+ # Unranked candidates share the remaining points {0 .. m-k-1} equally.
30
+ avg_remaining = (m - k - 1) / 2 if m > k else 0.0
31
+ for cand in profile.candidates:
32
+ if cand not in ranking:
33
+ scores[cand] += weight * avg_remaining
34
+
35
+ return result_from_scores("borda", scores, tie_break=tie_break, seed=seed)
@@ -0,0 +1,48 @@
1
+ """Condorcet: the candidate that beats every other pairwise, or NO winner.
2
+
3
+ When the majority relation has a cycle (e.g. a > b > c > a) no Condorcet winner
4
+ exists; this returns empty ``winners`` and an explicit ``note`` rather than
5
+ inventing a winner.
6
+ """
7
+
8
+ from voting_mcp.rules._common import pairwise_tallies
9
+ from voting_mcp.types import Profile, Result, TieBreak
10
+
11
+
12
+ def condorcet(
13
+ profile: Profile,
14
+ *,
15
+ tie_break: TieBreak = TieBreak.LEXICOGRAPHIC,
16
+ seed: int = 0,
17
+ ) -> Result:
18
+ """Return the Condorcet winner, or no winner with a cycle note."""
19
+ tally = pairwise_tallies(profile, "condorcet")
20
+ cands = profile.candidates
21
+
22
+ winner: str | None = None
23
+ for x in cands:
24
+ if all(tally[(x, y)] > tally[(y, x)] for y in cands if y != x):
25
+ winner = x
26
+ break
27
+
28
+ # A descending pairwise-win ranking, useful even when no Condorcet winner exists.
29
+ wins = {x: sum(1 for y in cands if y != x and tally[(x, y)] > tally[(y, x)]) for x in cands}
30
+ ranking = sorted(cands, key=lambda c: (-wins[c], c))
31
+
32
+ if winner is None:
33
+ return Result(
34
+ rule="condorcet",
35
+ winners=[],
36
+ winner=None,
37
+ ranking=ranking,
38
+ tie_break=tie_break,
39
+ note="no Condorcet winner exists (the majority relation has a cycle)",
40
+ )
41
+ return Result(
42
+ rule="condorcet",
43
+ winners=[winner],
44
+ winner=winner,
45
+ ranking=ranking,
46
+ tie_break=tie_break,
47
+ note=None,
48
+ )
@@ -0,0 +1,29 @@
1
+ """Copeland: Condorcet-consistent pairwise scoring.
2
+
3
+ Each candidate scores +1 for every opponent it beats pairwise and +0.5 for
4
+ every pairwise tie. A Condorcet winner beats everyone and so tops the table.
5
+ """
6
+
7
+ from voting_mcp.rules._common import pairwise_tallies
8
+ from voting_mcp.scoring import result_from_scores
9
+ from voting_mcp.types import Profile, Result, TieBreak
10
+
11
+
12
+ def copeland(
13
+ profile: Profile,
14
+ *,
15
+ tie_break: TieBreak = TieBreak.LEXICOGRAPHIC,
16
+ seed: int = 0,
17
+ ) -> Result:
18
+ """Copeland score = (#pairwise wins) + 0.5 * (#pairwise ties)."""
19
+ tally = pairwise_tallies(profile, "copeland")
20
+ scores = dict.fromkeys(profile.candidates, 0.0)
21
+ for x in profile.candidates:
22
+ for y in profile.candidates:
23
+ if x == y:
24
+ continue
25
+ if tally[(x, y)] > tally[(y, x)]:
26
+ scores[x] += 1.0
27
+ elif tally[(x, y)] == tally[(y, x)]:
28
+ scores[x] += 0.5
29
+ return result_from_scores("copeland", scores, tie_break=tie_break, seed=seed)
@@ -0,0 +1,49 @@
1
+ """Majority: a candidate wins only with a strict majority of first choices.
2
+
3
+ This is the principled majority criterion, distinct from plurality: if no
4
+ candidate holds more than half the vote weight, there is NO winner and the
5
+ ``note`` says so. (The bench's naive baseline uses plurality/mode; this rule is
6
+ the stricter social-choice notion.)
7
+ """
8
+
9
+ from voting_mcp.rules._common import rankings
10
+ from voting_mcp.scoring import break_tie
11
+ from voting_mcp.types import Profile, Result, TieBreak
12
+
13
+
14
+ def majority(
15
+ profile: Profile,
16
+ *,
17
+ tie_break: TieBreak = TieBreak.LEXICOGRAPHIC,
18
+ seed: int = 0,
19
+ ) -> Result:
20
+ """Winner iff some candidate has > 50% of first-choice weight, else no winner."""
21
+ scores = dict.fromkeys(profile.candidates, 0.0)
22
+ for ranking, weight in rankings(profile, "majority"):
23
+ if ranking:
24
+ scores[ranking[0]] += weight
25
+
26
+ total = sum(scores.values())
27
+ ranking_order = sorted(scores, key=lambda c: (-scores[c], c))
28
+
29
+ leaders = [c for c, s in scores.items() if s == max(scores.values())]
30
+ top_votes = scores[leaders[0]] if leaders else 0.0
31
+ if total > 0 and len(leaders) == 1 and top_votes > total / 2:
32
+ winners = leaders
33
+ note = None
34
+ else:
35
+ winners = []
36
+ note = (
37
+ f"no majority winner: top candidate(s) {sorted(leaders)} hold "
38
+ f"{top_votes} of {total} vote weight (need > {total / 2})"
39
+ )
40
+
41
+ return Result(
42
+ rule="majority",
43
+ winners=winners,
44
+ winner=break_tie(winners, tie_break, seed),
45
+ ranking=ranking_order,
46
+ scores=scores,
47
+ tie_break=tie_break,
48
+ note=note,
49
+ )
@@ -0,0 +1,31 @@
1
+ """Linear opinion pool: a weight-averaged probability distribution.
2
+
3
+ Unlike the other rules this is NOT an argmax vote — it preserves the ensemble's
4
+ confidence by pooling the full distributions: ``pooled[c] = (sum_b w_b *
5
+ p_b[c]) / sum_b w_b``. The pooled distribution is returned in ``scores``; the
6
+ winner is its argmax (with the usual tie-break) purely for convenience.
7
+ """
8
+
9
+ from voting_mcp.rules._common import distributions
10
+ from voting_mcp.scoring import result_from_scores
11
+ from voting_mcp.types import Profile, Result, TieBreak
12
+
13
+
14
+ def opinion_pool(
15
+ profile: Profile,
16
+ *,
17
+ tie_break: TieBreak = TieBreak.LEXICOGRAPHIC,
18
+ seed: int = 0,
19
+ ) -> Result:
20
+ """Pool distribution ballots linearly; ``scores`` is the pooled distribution."""
21
+ pooled = dict.fromkeys(profile.candidates, 0.0)
22
+ total_weight = 0.0
23
+ for dist, weight in distributions(profile, "opinion_pool"):
24
+ total_weight += weight
25
+ for cand, p in dist.items():
26
+ pooled[cand] += weight * p
27
+
28
+ if total_weight > 0:
29
+ pooled = {c: v / total_weight for c, v in pooled.items()}
30
+
31
+ return result_from_scores("opinion_pool", pooled, tie_break=tie_break, seed=seed)
@@ -0,0 +1,23 @@
1
+ """Plurality: the candidate with the most first-choice votes wins (baseline)."""
2
+
3
+ from voting_mcp.rules._common import rankings
4
+ from voting_mcp.scoring import result_from_scores
5
+ from voting_mcp.types import Profile, Result, TieBreak
6
+
7
+
8
+ def plurality(
9
+ profile: Profile,
10
+ *,
11
+ tie_break: TieBreak = TieBreak.LEXICOGRAPHIC,
12
+ seed: int = 0,
13
+ ) -> Result:
14
+ """Most first-place votes wins. Consumes ranking ballots (uses the top entry).
15
+
16
+ Ties for the most votes are surfaced in ``winners``; ``winner`` applies the
17
+ tie-break. This is the naive mode-of-votes baseline.
18
+ """
19
+ scores = dict.fromkeys(profile.candidates, 0.0)
20
+ for ranking, weight in rankings(profile, "plurality"):
21
+ if ranking:
22
+ scores[ranking[0]] += weight
23
+ return result_from_scores("plurality", scores, tie_break=tie_break, seed=seed)
@@ -0,0 +1,74 @@
1
+ """Single-winner STV (a.k.a. instant-runoff): rounds of elimination + transfer.
2
+
3
+ Each round counts every ballot's top *still-active* preference. If a candidate
4
+ holds a strict majority of the active weight, it wins. Otherwise the candidate
5
+ with the fewest votes is eliminated and its ballots transfer to their next
6
+ active preference; a ballot with no remaining preference is *exhausted* and
7
+ drops out of the active total. Clone-resistant, unlike plurality.
8
+
9
+ Tie-breaks (both documented, deterministic):
10
+ * elimination tie -> eliminate the lexicographically largest candidate;
11
+ * final-winner tie -> reported as co-winners, resolved by ``tie_break``.
12
+ """
13
+
14
+ from voting_mcp.rules._common import rankings
15
+ from voting_mcp.scoring import break_tie
16
+ from voting_mcp.types import Profile, Result, TieBreak
17
+
18
+
19
+ def _round_tally(
20
+ ballots: list[tuple[list[str], float]], active: set[str]
21
+ ) -> dict[str, float]:
22
+ scores = dict.fromkeys(active, 0.0)
23
+ for ranking, weight in ballots:
24
+ for cand in ranking:
25
+ if cand in active:
26
+ scores[cand] += weight
27
+ break # first active preference only; else the ballot is exhausted
28
+ return scores
29
+
30
+
31
+ def stv(
32
+ profile: Profile,
33
+ *,
34
+ tie_break: TieBreak = TieBreak.LEXICOGRAPHIC,
35
+ seed: int = 0,
36
+ ) -> Result:
37
+ """Run single-winner STV and return the winner (or final co-winners)."""
38
+ ballots = rankings(profile, "stv")
39
+ active = set(profile.candidates)
40
+ rounds = 0
41
+
42
+ while True:
43
+ rounds += 1
44
+ scores = _round_tally(ballots, active)
45
+ total = sum(scores.values())
46
+
47
+ if total > 0:
48
+ top = max(scores.values())
49
+ leaders = [c for c in active if scores[c] == top]
50
+ if len(leaders) == 1 and top > total / 2:
51
+ return Result(
52
+ rule="stv",
53
+ winners=leaders,
54
+ winner=leaders[0],
55
+ scores=scores,
56
+ tie_break=tie_break,
57
+ note=f"majority reached in round {rounds}",
58
+ )
59
+
60
+ if len(active) <= 2:
61
+ top = max(scores.values()) if scores else 0.0
62
+ winners = sorted(c for c in active if scores.get(c, 0.0) == top)
63
+ return Result(
64
+ rule="stv",
65
+ winners=winners,
66
+ winner=break_tie(winners, tie_break, seed),
67
+ scores=scores,
68
+ tie_break=tie_break,
69
+ note=f"resolved among final {len(active)} candidates in round {rounds}",
70
+ )
71
+
72
+ low = min(scores.values())
73
+ eliminate = max(c for c in active if scores[c] == low)
74
+ active.discard(eliminate)
voting_mcp/scoring.py ADDED
@@ -0,0 +1,64 @@
1
+ """Shared helpers for score-based rules: tie-breaking and Result assembly.
2
+
3
+ Pure functions, deterministic. The tie-break policy only affects the single
4
+ ``Result.winner`` selection — ``Result.winners`` always reports the full set of
5
+ candidates tied for the top, so a tie is never silently hidden.
6
+ """
7
+
8
+ import math
9
+ import random
10
+
11
+ from voting_mcp.types import Result, TieBreak
12
+
13
+ # Two scores within this absolute tolerance count as tied (guards float noise
14
+ # in e.g. the linear opinion pool; exact for integer Borda/Copeland tallies).
15
+ _SCORE_TOL = 1e-9
16
+
17
+
18
+ def break_tie(tied: list[str], tie_break: TieBreak, seed: int = 0) -> str | None:
19
+ """Resolve a set of tied candidates to a single winner, or ``None``.
20
+
21
+ A lone candidate always resolves to itself. With more than one tied:
22
+ ``LEXICOGRAPHIC`` -> smallest id, ``RANDOM`` -> seeded order-independent
23
+ choice, ``NONE`` -> ``None`` (deliberately unresolved).
24
+ """
25
+ if not tied:
26
+ return None
27
+ if len(tied) == 1:
28
+ return tied[0]
29
+ if tie_break is TieBreak.LEXICOGRAPHIC:
30
+ return min(tied)
31
+ if tie_break is TieBreak.RANDOM:
32
+ return random.Random(seed).choice(sorted(tied))
33
+ return None # TieBreak.NONE
34
+
35
+
36
+ def result_from_scores(
37
+ rule: str,
38
+ scores: dict[str, float],
39
+ *,
40
+ tie_break: TieBreak = TieBreak.LEXICOGRAPHIC,
41
+ seed: int = 0,
42
+ note: str | None = None,
43
+ ) -> Result:
44
+ """Build a Result from a per-candidate score map (higher is better).
45
+
46
+ ``scores`` must cover every candidate. Winners are all candidates within
47
+ ``_SCORE_TOL`` of the maximum; the ranking sorts by descending score with
48
+ ties broken lexicographically so it is fully deterministic.
49
+ """
50
+ if not scores:
51
+ return Result(rule=rule, winners=[], winner=None, tie_break=tie_break, note=note)
52
+
53
+ top = max(scores.values())
54
+ winners = sorted(c for c, s in scores.items() if math.isclose(s, top, abs_tol=_SCORE_TOL))
55
+ ranking = sorted(scores, key=lambda c: (-scores[c], c))
56
+ return Result(
57
+ rule=rule,
58
+ winners=winners,
59
+ winner=break_tie(winners, tie_break, seed),
60
+ ranking=ranking,
61
+ scores=scores,
62
+ tie_break=tie_break,
63
+ note=note,
64
+ )
voting_mcp/server.py ADDED
@@ -0,0 +1,164 @@
1
+ """FastMCP server exposing the social-choice rules as tools.
2
+
3
+ Transport is stdio only. The server is pure compute: no network, no file
4
+ writes, no secrets — which keeps it clean against the OWASP MCP Top 10 by
5
+ construction. Each tool's docstring is the model-facing spec; inputs and
6
+ outputs are strict pydantic schemas (``additionalProperties: false``).
7
+
8
+ Input (all tools): a ``profile`` = ``{candidates: [...], ballots: [...]}`` where
9
+ each ballot has a ``kind`` ("ranking" | "approval" | "score" | "distribution")
10
+ and an optional ``weight`` (default 1). ``tie_break`` is "lexicographic"
11
+ (default), "none", or "random"; ``seed`` seeds the random tie-break.
12
+
13
+ Output (all tools): a ``Result`` = ``{rule, winners, winner, ranking, scores,
14
+ tie_break, note}``. ``winners`` is ALWAYS the full co-winner set (so ties are
15
+ never hidden); ``winner`` is the single tie-broken pick, or ``null`` when no
16
+ winner exists (e.g. a Condorcet cycle) or a tie is left unresolved.
17
+ """
18
+
19
+ from mcp.server.fastmcp import FastMCP
20
+
21
+ from voting_mcp.aggregate import RuleName, aggregate
22
+ from voting_mcp.rules.approval import approval as _approval
23
+ from voting_mcp.rules.borda import borda as _borda
24
+ from voting_mcp.rules.condorcet import condorcet as _condorcet
25
+ from voting_mcp.rules.copeland import copeland as _copeland
26
+ from voting_mcp.rules.majority import majority as _majority
27
+ from voting_mcp.rules.opinion_pool import opinion_pool as _opinion_pool
28
+ from voting_mcp.rules.plurality import plurality as _plurality
29
+ from voting_mcp.rules.stv import stv as _stv
30
+ from voting_mcp.types import Profile, Result, TieBreak
31
+
32
+ mcp = FastMCP("voting-mcp")
33
+
34
+
35
+ @mcp.tool()
36
+ def borda(profile: Profile, tie_break: TieBreak = TieBreak.LEXICOGRAPHIC, seed: int = 0) -> Result:
37
+ """Borda count over ranking ballots: positional scoring (top of m gets m-1).
38
+
39
+ Condorcet-inconsistent and clone-sensitive — useful as a contrast rule.
40
+ Truncated ballots give unranked candidates the average of the remaining
41
+ points (never a silent zero). Errors if any ballot is not a ranking.
42
+ """
43
+ return _borda(profile, tie_break=tie_break, seed=seed)
44
+
45
+
46
+ @mcp.tool()
47
+ def copeland(
48
+ profile: Profile, tie_break: TieBreak = TieBreak.LEXICOGRAPHIC, seed: int = 0
49
+ ) -> Result:
50
+ """Copeland over ranking ballots: +1 per pairwise win, +0.5 per pairwise tie.
51
+
52
+ Condorcet-consistent: a candidate that beats all others pairwise wins.
53
+ Errors if any ballot is not a ranking.
54
+ """
55
+ return _copeland(profile, tie_break=tie_break, seed=seed)
56
+
57
+
58
+ @mcp.tool()
59
+ def condorcet(
60
+ profile: Profile, tie_break: TieBreak = TieBreak.LEXICOGRAPHIC, seed: int = 0
61
+ ) -> Result:
62
+ """Condorcet winner over ranking ballots: beats every other candidate pairwise.
63
+
64
+ If the majority relation cycles (e.g. a>b>c>a) there is NO Condorcet winner:
65
+ ``winners`` is empty, ``winner`` is null, and ``note`` says so. Errors if any
66
+ ballot is not a ranking.
67
+ """
68
+ return _condorcet(profile, tie_break=tie_break, seed=seed)
69
+
70
+
71
+ @mcp.tool()
72
+ def approval(
73
+ profile: Profile, tie_break: TieBreak = TieBreak.LEXICOGRAPHIC, seed: int = 0
74
+ ) -> Result:
75
+ """Approval voting: each candidate scores the total weight approving it.
76
+
77
+ Consumes approval ballots (``{kind: "approval", approved: [...]}``). An empty
78
+ approval set is valid and adds nothing. Errors if any ballot is not approval.
79
+ """
80
+ return _approval(profile, tie_break=tie_break, seed=seed)
81
+
82
+
83
+ @mcp.tool()
84
+ def stv(profile: Profile, tie_break: TieBreak = TieBreak.LEXICOGRAPHIC, seed: int = 0) -> Result:
85
+ """Single-winner STV / instant-runoff over ranking ballots.
86
+
87
+ Rounds: count top active preferences; a strict majority wins, else eliminate
88
+ the fewest-voted (ties broken by eliminating the lexicographically largest)
89
+ and transfer. Ballots with no remaining preference are exhausted.
90
+ Clone-resistant. Errors if any ballot is not a ranking.
91
+ """
92
+ return _stv(profile, tie_break=tie_break, seed=seed)
93
+
94
+
95
+ @mcp.tool()
96
+ def opinion_pool(
97
+ profile: Profile, tie_break: TieBreak = TieBreak.LEXICOGRAPHIC, seed: int = 0
98
+ ) -> Result:
99
+ """Linear opinion pool over probability-distribution ballots.
100
+
101
+ Returns the weight-averaged distribution in ``scores`` (it PRESERVES
102
+ confidence rather than collapsing to an argmax vote); ``winner`` is the
103
+ argmax for convenience. Ballots are ``{kind: "distribution", distribution:
104
+ {cand: p, ...}}`` summing to 1. Errors if any ballot is not a distribution.
105
+ """
106
+ return _opinion_pool(profile, tie_break=tie_break, seed=seed)
107
+
108
+
109
+ @mcp.tool()
110
+ def plurality(
111
+ profile: Profile, tie_break: TieBreak = TieBreak.LEXICOGRAPHIC, seed: int = 0
112
+ ) -> Result:
113
+ """Plurality baseline over ranking ballots: most first-choice votes wins."""
114
+ return _plurality(profile, tie_break=tie_break, seed=seed)
115
+
116
+
117
+ @mcp.tool()
118
+ def majority(
119
+ profile: Profile, tie_break: TieBreak = TieBreak.LEXICOGRAPHIC, seed: int = 0
120
+ ) -> Result:
121
+ """Strict-majority rule over ranking ballots.
122
+
123
+ A candidate wins only with > 50% of first-choice weight; otherwise there is
124
+ NO winner (``winners`` empty, ``note`` explains). Distinct from plurality.
125
+ """
126
+ return _majority(profile, tie_break=tie_break, seed=seed)
127
+
128
+
129
+ @mcp.tool()
130
+ def aggregate_rule(
131
+ profile: Profile,
132
+ rule: RuleName,
133
+ tie_break: TieBreak = TieBreak.LEXICOGRAPHIC,
134
+ seed: int = 0,
135
+ ) -> Result:
136
+ """Apply any rule by name (enum ``rule``) — a single dispatch entrypoint.
137
+
138
+ Equivalent to the per-rule tools. ``rule`` is one of: borda, copeland,
139
+ condorcet, approval, stv, opinion_pool, plurality, majority.
140
+ """
141
+ return aggregate(profile, rule, tie_break=tie_break, seed=seed)
142
+
143
+
144
+ def _enforce_strict_schemas() -> None:
145
+ """Set ``additionalProperties: false`` on every tool's top-level input.
146
+
147
+ FastMCP marks our nested pydantic models strict (via ``extra="forbid"``) but
148
+ leaves the outer argument object open; CLAUDE.md requires fully strict
149
+ schemas, so we close it here.
150
+ """
151
+ for tool in mcp._tool_manager.list_tools():
152
+ tool.parameters["additionalProperties"] = False
153
+
154
+
155
+ _enforce_strict_schemas()
156
+
157
+
158
+ def main() -> None:
159
+ """Console-script entrypoint: serve over stdio."""
160
+ mcp.run()
161
+
162
+
163
+ if __name__ == "__main__":
164
+ main()
voting_mcp/types.py ADDED
@@ -0,0 +1,145 @@
1
+ """Ballot / Profile / Result schemas.
2
+
3
+ Design notes
4
+ ------------
5
+ * Ballots are a discriminated union on ``kind``. ``extra="forbid"`` means an
6
+ unknown field is an error, not silently dropped.
7
+ * A ``RankingBallot`` may be *strict* (lists every candidate) or *truncated*
8
+ (lists a subset) — both are valid; rules define how they treat the tail.
9
+ * Cross-field validation (a ballot may only reference candidates in the
10
+ profile's candidate set) lives on ``Profile`` so it is enforced in one place.
11
+ * ``Result.winners`` ALWAYS carries the full co-winner set, so a tie is never
12
+ hidden. ``Result.winner`` is the single tie-broken selection (``None`` when
13
+ there is no winner, e.g. a Condorcet cycle, or when ``tie_break=none`` leaves
14
+ a genuine tie unresolved).
15
+ """
16
+
17
+ from enum import StrEnum
18
+ from typing import Annotated, Literal, Self
19
+
20
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
21
+
22
+ # Tolerance for "a probability distribution sums to 1". Strict on purpose:
23
+ # callers normalize before constructing — the type never silently coerces.
24
+ _PROB_SUM_TOL = 1e-6
25
+
26
+
27
+ class TieBreak(StrEnum):
28
+ """How a rule resolves a tie into the single ``Result.winner``.
29
+
30
+ The full tied set is always reported in ``Result.winners`` regardless.
31
+ """
32
+
33
+ LEXICOGRAPHIC = "lexicographic" # smallest candidate id wins — deterministic default
34
+ NONE = "none" # leave it unresolved: winner=None when >1 tied
35
+ RANDOM = "random" # seeded choice — deterministic given the seed
36
+
37
+
38
+ class _BallotBase(BaseModel):
39
+ model_config = ConfigDict(extra="forbid")
40
+ weight: float = Field(default=1.0, gt=0.0)
41
+
42
+
43
+ class RankingBallot(_BallotBase):
44
+ """An ordering of candidates, best first. May be truncated (a subset)."""
45
+
46
+ kind: Literal["ranking"] = "ranking"
47
+ ranking: list[str]
48
+
49
+ @model_validator(mode="after")
50
+ def _no_duplicates(self) -> Self:
51
+ if len(set(self.ranking)) != len(self.ranking):
52
+ raise ValueError("ranking contains duplicate candidates")
53
+ return self
54
+
55
+
56
+ class ApprovalBallot(_BallotBase):
57
+ """A set of approved candidates."""
58
+
59
+ kind: Literal["approval"] = "approval"
60
+ approved: list[str]
61
+
62
+ @model_validator(mode="after")
63
+ def _no_duplicates(self) -> Self:
64
+ if len(set(self.approved)) != len(self.approved):
65
+ raise ValueError("approval set contains duplicate candidates")
66
+ return self
67
+
68
+
69
+ class ScoreBallot(_BallotBase):
70
+ """A utility/score per candidate. No normalization constraint."""
71
+
72
+ kind: Literal["score"] = "score"
73
+ scores: dict[str, float]
74
+
75
+
76
+ class DistributionBallot(_BallotBase):
77
+ """A probability distribution over candidates (sums to 1)."""
78
+
79
+ kind: Literal["distribution"] = "distribution"
80
+ distribution: dict[str, float]
81
+
82
+ @model_validator(mode="after")
83
+ def _valid_distribution(self) -> Self:
84
+ for cand, p in self.distribution.items():
85
+ if not 0.0 <= p <= 1.0:
86
+ raise ValueError(f"probability for {cand!r} out of [0, 1]: {p}")
87
+ total = sum(self.distribution.values())
88
+ if abs(total - 1.0) > _PROB_SUM_TOL:
89
+ raise ValueError(f"distribution must sum to 1, got {total}")
90
+ return self
91
+
92
+
93
+ Ballot = Annotated[
94
+ RankingBallot | ApprovalBallot | ScoreBallot | DistributionBallot,
95
+ Field(discriminator="kind"),
96
+ ]
97
+
98
+
99
+ def _referenced_candidates(ballot: Ballot) -> set[str]:
100
+ """Every candidate a ballot mentions — used for subset validation."""
101
+ if isinstance(ballot, RankingBallot):
102
+ return set(ballot.ranking)
103
+ if isinstance(ballot, ApprovalBallot):
104
+ return set(ballot.approved)
105
+ if isinstance(ballot, ScoreBallot):
106
+ return set(ballot.scores)
107
+ return set(ballot.distribution)
108
+
109
+
110
+ class Profile(BaseModel):
111
+ """A candidate set plus ballots, all over that same candidate set."""
112
+
113
+ model_config = ConfigDict(extra="forbid")
114
+
115
+ candidates: list[str]
116
+ ballots: list[Ballot]
117
+
118
+ @model_validator(mode="after")
119
+ def _validate(self) -> Self:
120
+ if not self.candidates:
121
+ raise ValueError("candidate set must be non-empty")
122
+ if len(set(self.candidates)) != len(self.candidates):
123
+ raise ValueError("candidate set contains duplicates")
124
+ known = set(self.candidates)
125
+ for i, ballot in enumerate(self.ballots):
126
+ unknown = _referenced_candidates(ballot) - known
127
+ if unknown:
128
+ raise ValueError(
129
+ f"ballot {i} references candidates not in the set: {sorted(unknown)}"
130
+ )
131
+ return self
132
+
133
+
134
+ class Result(BaseModel):
135
+ """The outcome of applying a rule to a profile."""
136
+
137
+ model_config = ConfigDict(extra="forbid")
138
+
139
+ rule: str
140
+ winners: list[str] # full co-winner set; len>1 => tie; [] => no winner exists
141
+ winner: str | None # single tie-broken selection; None if no winner / unresolved tie
142
+ ranking: list[str] | None = None # full social ranking where the rule defines one
143
+ scores: dict[str, float] | None = None # per-candidate score the winner derives from
144
+ tie_break: TieBreak = TieBreak.LEXICOGRAPHIC
145
+ note: str | None = None # e.g. "no Condorcet winner exists (cycle a > b > c > a)"
@@ -0,0 +1,143 @@
1
+ Metadata-Version: 2.4
2
+ Name: voting-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server exposing principled social-choice aggregation rules (Borda, Copeland, Condorcet, approval, STV, opinion pool), with a reproducible benchmark measuring their accuracy vs majority vote over an LLM ensemble.
5
+ Author-email: Hrishi Kabra <kabrahrishi@gmail.com>
6
+ License: MIT
7
+ Keywords: aggregation,ensemble,mcp,social-choice,voting
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
13
+ Requires-Python: >=3.12
14
+ Requires-Dist: mcp[cli]>=1.2.0
15
+ Requires-Dist: pydantic>=2.6
16
+ Provides-Extra: bench
17
+ Requires-Dist: matplotlib>=3.8; extra == 'bench'
18
+ Requires-Dist: numpy>=1.26; extra == 'bench'
19
+ Requires-Dist: openai>=1.30; extra == 'bench'
20
+ Requires-Dist: python-dotenv>=1.0; extra == 'bench'
21
+ Requires-Dist: pyyaml>=6.0; extra == 'bench'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # voting-mcp
25
+
26
+ **Principled social-choice aggregation as MCP tools — with a benchmark that measures the
27
+ accuracy lift over naive majority vote.**
28
+
29
+ Almost every multi-agent system aggregates votes with `Counter(votes).most_common(1)`, throwing
30
+ away preference order and confidence. `voting-mcp` ships the real rules (Borda, Copeland,
31
+ Condorcet, approval, STV, linear opinion pool) as callable MCP tools — each with its known
32
+ axiomatic behavior and explicit, documented tie-breaking — plus a reproducible benchmark that
33
+ aggregates a diverse ensemble of LLMs on a reasoning set and reports accuracy with bootstrap
34
+ confidence intervals.
35
+
36
+ The server is **pure compute**: stdio transport, no network, no file writes, no secrets — clean
37
+ against the OWASP MCP Top 10 by construction.
38
+
39
+ ## Install
40
+
41
+ ```sh
42
+ # run the server directly (once published)
43
+ uvx voting-mcp
44
+
45
+ # or from source
46
+ git clone <repo> && cd voting-mcp
47
+ uv sync
48
+ uv run python -m voting_mcp.server
49
+ ```
50
+
51
+ Add it to an MCP client (e.g. Claude Desktop `claude_desktop_config.json`):
52
+
53
+ ```json
54
+ {
55
+ "mcpServers": {
56
+ "voting": { "command": "uvx", "args": ["voting-mcp"] }
57
+ }
58
+ }
59
+ ```
60
+
61
+ ## Tools
62
+
63
+ Every tool takes a `profile` (`{candidates, ballots}`) and returns a `Result` with the full
64
+ co-winner set (`winners`, so ties are never hidden), the single tie-broken `winner` (or `null`
65
+ when none exists), a `ranking`, per-candidate `scores`, and a `note`.
66
+
67
+ | Tool | Ballots | Notes |
68
+ |------|---------|-------|
69
+ | `borda` | rankings | positional; Condorcet-inconsistent, clone-sensitive |
70
+ | `copeland` | rankings | Condorcet-consistent pairwise (+1 win, +0.5 tie) |
71
+ | `condorcet` | rankings | returns the pairwise winner **or an explicit no-winner on a cycle** |
72
+ | `approval` | approval sets | most-approved wins |
73
+ | `stv` | rankings | single-winner instant-runoff; clone-resistant |
74
+ | `opinion_pool` | distributions | linear pool — **preserves confidence, not an argmax vote** |
75
+ | `plurality` | rankings | baseline (most first choices) |
76
+ | `majority` | rankings | strict >50% or **no winner** |
77
+ | `aggregate_rule` | any | dispatch by a `rule` enum |
78
+
79
+ Tie-breaking is an explicit parameter (`lexicographic` default, `none`, or seeded `random`).
80
+
81
+ ## Benchmark
82
+
83
+ Aggregate an ensemble of 5 models (one OpenAI-compatible client via OpenRouter) on
84
+ ARC-Challenge and compare each rule to the naive majority vote:
85
+
86
+ ```sh
87
+ uv sync --extra bench
88
+ uv run python -m bench.fetch_arc --limit 200
89
+ # prints a cost estimate and STOPS; add --yes to actually call the API, --mock for a free dry run
90
+ uv run python -m bench.run_ensemble --dataset bench/datasets/arc_challenge.jsonl --limit 200 --yes
91
+ uv run python -m bench.compare --dataset bench/datasets/arc_challenge.jsonl --limit 200
92
+ ```
93
+
94
+ Every raw response is cached under `bench/results/raw/`; re-runs never re-call the API, so
95
+ aggregation tweaks are free.
96
+
97
+ ### Results
98
+
99
+ 5-model ensemble (gpt-4o-mini · gemini-2.5-flash-lite · deepseek-v3 · claude-haiku-4.5 ·
100
+ glm-4.7), n = 200, bootstrap 95% CI. Two datasets of different difficulty; full write-up and
101
+ both plots in [`RESULTS.md`](RESULTS.md).
102
+
103
+ **MMLU-Pro (hard, baseline 73.5%) — the informative case:**
104
+
105
+ | Rule | Accuracy | 95% CI | Δ vs majority |
106
+ |------|---------:|:------:|--------------:|
107
+ | **opinion_pool** | **0.755** | [0.695, 0.815] | **+0.020** |
108
+ | **majority_vote (baseline)** | 0.735 | [0.679, 0.788] | — |
109
+ | approval | 0.701 | [0.640, 0.757] | −0.035 |
110
+ | stv | 0.693 | [0.630, 0.750] | −0.043 |
111
+ | copeland | 0.647 | [0.580, 0.710] | −0.088 |
112
+ | condorcet | 0.620 | [0.550, 0.685] | −0.115 |
113
+ | majority (strict) | 0.590 | [0.520, 0.655] | −0.145 |
114
+ | borda | 0.472 | [0.405, 0.540] | −0.263 |
115
+
116
+ ![MMLU-Pro](docs/accuracy_mmlu_pro.png)
117
+
118
+ **The finding (honest):** the value isn't "fancy voting beats majority." It's that **the
119
+ confidence-preserving rule (`opinion_pool`) wins** when the crowd is uncertain (+2.0pp, the only
120
+ rule above baseline — though its CI still overlaps, so *suggestive, not conclusive*), while
121
+ **forcing the distributions into full rankings actively hurts** — `borda` collapses to 0.472,
122
+ far below majority, because with 10 options the tail of the ranking is mostly noise. Aggregate
123
+ the confidence; don't throw it away. On **ARC-Challenge** (baseline 96.8%, near-ceiling) nothing
124
+ separates — every rule lands within overlapping CIs. See [`RESULTS.md`](RESULTS.md).
125
+
126
+ ## Develop
127
+
128
+ ```sh
129
+ uv run pytest -q
130
+ uv run ruff check .
131
+ uv run mypy --strict src
132
+ # exercise the tools in the MCP Inspector:
133
+ npx @modelcontextprotocol/inspector uv run python -m voting_mcp.server
134
+ ```
135
+
136
+ > Note: if you keep this repo under an iCloud-synced folder (e.g. `~/Desktop`), iCloud can spawn
137
+ > duplicate `.pth` files that intermittently break the editable install. Tests use
138
+ > `pythonpath=src`; run the server with `PYTHONPATH=src` if an import fails, or move the repo
139
+ > off the synced folder.
140
+
141
+ ## License
142
+
143
+ MIT
@@ -0,0 +1,19 @@
1
+ voting_mcp/__init__.py,sha256=fRNoL8wCTCDt7zF3yCVcDeGX37bix0GhOBq5Wt1v8_Y,98
2
+ voting_mcp/aggregate.py,sha256=YpQunLKnOST_RgGx5OEETcolr7Sprt-bnvww3-kVeKs,1363
3
+ voting_mcp/scoring.py,sha256=cXJjxd_1BAA5dsB6XgEqHOwixmCDioNgPI80ctFYfB8,2208
4
+ voting_mcp/server.py,sha256=AYCvbI8s3YXkJv45hsgLzRTLw05Yk9FMWYDf7ZvXZNU,6221
5
+ voting_mcp/types.py,sha256=doJlNXpmp-zY46F5UtGAcGy53BNioEYzfJQFcqvbIW0,5171
6
+ voting_mcp/rules/__init__.py,sha256=sK2-vMqEzKtuoRx6JJ1Kc309JS4M6ZMEHOf2W4LSEk0,366
7
+ voting_mcp/rules/_common.py,sha256=VI7cqKaAbqov5J6cxPY91c1ZuulI7UWluFNvs9WrmGw,2758
8
+ voting_mcp/rules/approval.py,sha256=WOlhK7XyAsJGZON0Sg1sDNVI7AgWzYSjn3NFTn7ONuo,702
9
+ voting_mcp/rules/borda.py,sha256=HrXowd1IcUg9itIRe_C9Kj8rGV91pHiqQBwiTnGd_T8,1372
10
+ voting_mcp/rules/condorcet.py,sha256=_ksI19aMHPS5m22g5OIsZmlpvYmgIUFqaabd1Zo0oH0,1506
11
+ voting_mcp/rules/copeland.py,sha256=aG8NZ6dYg1tVEpbovdQmzpE90zvMlt7hvz8q2Kv5OzU,1029
12
+ voting_mcp/rules/majority.py,sha256=As7pN4j_szuavWeb7CI5VVx5Y2vMf-icpbDet7pj6Ko,1650
13
+ voting_mcp/rules/opinion_pool.py,sha256=2l7nLXbYwNeVUDRfcgeAebhnuzEhdmpPerZtP6OdV8Q,1176
14
+ voting_mcp/rules/plurality.py,sha256=7ZOai4dP46KWTcqmdiIg95UCtbhQmWW_rAjQ_Ccb6tQ,845
15
+ voting_mcp/rules/stv.py,sha256=TIxVYNZmD22Kh5_rHSulrjIwxmKoXZ1ZgBqdPBDSyaU,2679
16
+ voting_mcp-0.1.0.dist-info/METADATA,sha256=SZzr602nEcRy2eCMyUYbMoAA30romUdw34C3hFBVlvA,5914
17
+ voting_mcp-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
18
+ voting_mcp-0.1.0.dist-info/entry_points.txt,sha256=IBy0FYDQFJoihQIBi66Y9U4d38-2FSfQUUCk1LO9BPs,54
19
+ voting_mcp-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ voting-mcp = voting_mcp.server:main