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 +3 -0
- voting_mcp/aggregate.py +54 -0
- voting_mcp/rules/__init__.py +23 -0
- voting_mcp/rules/_common.py +69 -0
- voting_mcp/rules/approval.py +19 -0
- voting_mcp/rules/borda.py +35 -0
- voting_mcp/rules/condorcet.py +48 -0
- voting_mcp/rules/copeland.py +29 -0
- voting_mcp/rules/majority.py +49 -0
- voting_mcp/rules/opinion_pool.py +31 -0
- voting_mcp/rules/plurality.py +23 -0
- voting_mcp/rules/stv.py +74 -0
- voting_mcp/scoring.py +64 -0
- voting_mcp/server.py +164 -0
- voting_mcp/types.py +145 -0
- voting_mcp-0.1.0.dist-info/METADATA +143 -0
- voting_mcp-0.1.0.dist-info/RECORD +19 -0
- voting_mcp-0.1.0.dist-info/WHEEL +4 -0
- voting_mcp-0.1.0.dist-info/entry_points.txt +2 -0
voting_mcp/__init__.py
ADDED
voting_mcp/aggregate.py
ADDED
|
@@ -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)
|
voting_mcp/rules/stv.py
ADDED
|
@@ -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
|
+

|
|
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,,
|