Setup: encoding the 48 election states¶
In [10]:
import math
import numpy as np
import pandas as pd
from itertools import permutations
from scipy.special import ndtr # standard normal CDF
rng = np.random.default_rng(2026)
# Margin columns: 0 = A-vs-B, 1 = B-vs-C, 2 = C-vs-A.
# Sign convention: mAB > 0 means A beats B; mBC > 0 means B beats C;
# mCA > 0 means C beats A.
pos_pair = [('A','B'), ('B','C'), ('C','A')] # (winner, loser) if margin > 0
neg_pair = [('B','A'), ('C','B'), ('A','C')] # (winner, loser) if margin < 0
perms3 = list(permutations([0, 1, 2]))
def state_tuple(order, signs):
return tuple(pos_pair[c] if signs[c] > 0 else neg_pair[c] for c in order)
def is_cyclic(state):
wins = {c: set() for c in 'ABC'}
for w, l in state:
wins[w].add(l)
return not any(wins[c] == {'A','B','C'} - {c} for c in 'ABC')
# Look-up tables over all 48 states, indexed by (magnitude order, sign pattern)
cyclic_lut = np.zeros((6, 8), dtype=bool)
tuple_to_code = {}
for oi, order in enumerate(perms3):
for sc in range(8):
signs = [1 if (sc >> k) & 1 else -1 for k in range(3)]
st = state_tuple(order, signs)
cyclic_lut[oi, sc] = is_cyclic(st)
tuple_to_code[st] = (oi, sc)
# The six named states for the coalition A > B > C (verbatim from the post),
# with the chain "depth": how many rank-steps the lie must push the margins.
G1 = (('C','B'), ('B','A'), ('A','C'))
G2 = (('B','C'), ('A','B'), ('C','A'))
start1 = (('B','A'), ('A','C'), ('B','C'))
start2 = (('B','A'), ('B','C'), ('A','C'))
start3 = (('B','C'), ('B','A'), ('A','C'))
startBetrayal = (('B','C'), ('C','A'), ('A','B'))
named = [('start1', start1, 4), ('start2', start2, 3), ('start3', start3, 2),
('G1', G1, 1), ('betrayal start', startBetrayal, 2), ('G2', G2, 1)]
BURIAL_IDS = {0, 1, 2, 3} # states manipulated by the C>B "burial" lie
GATE_IDS = {3, 5} # G1 and G2
state_id_lut = np.full((6, 8), -1, dtype=int)
for sid, (_, st, _) in enumerate(named):
oi, sc = tuple_to_code[st]
state_id_lut[oi, sc] = sid
_perm_arr = np.array(perms3)
_lut27 = np.zeros(27, dtype=int)
for i, row in enumerate(_perm_arr):
_lut27[row[0]*9 + row[1]*3 + row[2]] = i
def classify(mAB, mBC, mCA):
'''For arrays of margins, return (is_cyclic, state_id) where state_id
indexes the six named states (-1 = any other state).'''
M = np.stack([mAB, mBC, mCA], axis=1)
order = np.argsort(np.abs(M), axis=1)
oi = _lut27[order[:,0]*9 + order[:,1]*3 + order[:,2]]
sc = (mAB > 0).astype(int) + 2*(mBC > 0).astype(int) + 4*(mCA > 0).astype(int)
return cyclic_lut[oi, sc], state_id_lut[oi, sc]
def manip_delta(sid, mAB, mBC):
'''delta* = minimal insincere-vote fraction (of the electorate) needed to
push a state to its gate: half the gap between the closest two matchups,
signed for whichever lie (burial vs. betrayal) the state calls for.
NaN outside the six named states.'''
delta = np.full(len(sid), np.nan)
bur, bet = np.isin(sid, list(BURIAL_IDS)), (sid >= 4)
delta[bur] = (mBC[bur] - mAB[bur]) / 2
delta[bet] = (mAB[bet] - mBC[bet]) / 2
return delta
The cast of voter models¶
In [11]:
# Pairwise-vote vectors for the 6 rankings, in the order
# ABC, ACB, BAC, BCA, CAB, CBA (so index 0 = the coalition's ranking)
Y_AB = np.array([ 1, 1, -1, -1, 1, -1], dtype=float)
Y_BC = np.array([ 1, -1, 1, 1, -1, -1], dtype=float)
Y_CA = np.array([-1, -1, -1, 1, 1, 1], dtype=float)
cultures = {} # name -> dict(mAB, mBC, mCA [fractions], pool)
def add_ranking_culture(name, weights, n_voters, N, Y_AB=Y_AB, Y_BC=Y_BC, Y_CA=Y_CA):
'''Cultures where we literally simulate n_voters ballots N times.'''
counts = rng.multinomial(n_voters, weights, size=N)
mAB, mBC, mCA = (counts @ Y_AB, counts @ Y_BC, counts @ Y_CA)
# discard exact ties (rare, and merely a knife-edge technicality)
keep = (mAB != 0) & (mBC != 0) & (mCA != 0)
keep &= ((np.abs(mAB) != np.abs(mBC)) & (np.abs(mBC) != np.abs(mCA))
& (np.abs(mAB) != np.abs(mCA)))
cultures[name] = dict(
mAB=mAB[keep]/n_voters, mBC=mBC[keep]/n_voters, mCA=mCA[keep]/n_voters,
pool=counts[keep, 0]/n_voters)
N = 500_000
add_ranking_culture('IC, 1,001 voters', np.full(6, 1/6), 1001, N)
add_ranking_culture('IC, 10,001 voters', np.full(6, 1/6), 10001, N)
# Unlike a typical Condorcet ballot, Better Choices lets a voter cast an
# outright cyclic vote (A>B, B>C, C>A -- not a valid ranking, but a valid
# ballot here). Extend impartial culture from the 6 transitive rankings to
# all 8 possible tournaments over 3 candidates by adding the two cyclic
# profiles, each drawn with the same uniform weight as the other six.
Y_AB8 = np.append(Y_AB, [ 1, -1])
Y_BC8 = np.append(Y_BC, [ 1, -1])
Y_CA8 = np.append(Y_CA, [ 1, -1])
add_ranking_culture('IC, 8 profiles (incl. cyclic ballots)', np.full(8, 1/8), 1001, N,
Y_AB=Y_AB8, Y_BC=Y_BC8, Y_CA=Y_CA8)
# Mallows model: voters' rankings are drawn from a distribution centered around a reference ranking with a dispersion parameter phi.
phi = 0.97
w = phi ** np.array([0, 1, 1, 2, 2, 3]); w /= w.sum() # normalize to sum to 1
add_ranking_culture('Mallows (mild consensus)', w, 1001, N)
# IAC: society-wide ranking shares uniform on the simplex (Dirichlet(1,..,1));
# with many voters the margins are then determined by the shares.
P = rng.dirichlet(np.ones(6), size=2*N)
cultures['IAC'] = dict(mAB=P @ Y_AB, mBC=P @ Y_BC, mCA=P @ Y_CA, pool=P[:, 0])
# 2D spatial model: 3 Gaussian voter clusters, Gaussian candidates.
# Each matchup's vote share is an exact Gaussian half-plane measure
# (the perpendicular bisector of two candidates splits the plane).
K, tau, s, Nsp = 3, 1.0, 0.5, 400_000
cand = rng.normal(0, 1, size=(Nsp, 3, 2))
centers = rng.normal(0, tau, size=(Nsp, K, 2))
wts = rng.dirichlet(np.ones(K), size=Nsp)
def spatial_margin(ci, cj):
a = 2*(cj - ci)
b = (cj**2).sum(axis=1) - (ci**2).sum(axis=1)
z = (b[:, None] - np.einsum('nd,nkd->nk', a, centers)) \
/ (s * np.linalg.norm(a, axis=1)[:, None])
return 2*(wts * ndtr(z)).sum(axis=1) - 1
A_, B_, C_ = cand[:, 0], cand[:, 1], cand[:, 2]
sp = dict(mAB=spatial_margin(A_, B_), mBC=spatial_margin(B_, C_),
mCA=spatial_margin(C_, A_))
# The spatial pool (share of voters ranking A > B > C) has no simple formula,
# so estimate it by sampling voters -- but only for elections that land in
# one of the six states (the only place we need it).
_, sid_sp = classify(sp['mAB'], sp['mBC'], sp['mCA'])
pool = np.full(Nsp, np.nan)
hits, V = np.flatnonzero(sid_sp >= 0), 500
for lo in range(0, len(hits), 5000):
idx = hits[lo:lo+5000]
cid = (rng.random((len(idx), V, 1))
> np.cumsum(wts[idx], axis=1)[:, None, :]).sum(axis=2)
mu = np.take_along_axis(centers[idx], cid[:, :, None], axis=1)
x = mu + rng.normal(0, s, size=(len(idx), V, 2))
d2 = ((x[:, :, None, :] - cand[idx][:, None, :, :])**2).sum(axis=3)
pool[idx] = ((d2[:,:,0] < d2[:,:,1]) & (d2[:,:,1] < d2[:,:,2])).mean(axis=1)
sp['pool'] = pool
cultures['2D spatial (3 factions)'] = sp
# Same voter geometry (centers, wts) as above, but now each candidate is
# literally a random voter: pick a faction (same inverse-CDF trick used for
# the pool below), then draw centers[faction] + N(0, s) -- the exact
# distribution real voters are drawn from -- instead of an independent
# N(0, 1) unrelated to the electorate.
cid_c = (rng.random((Nsp, 3, 1)) > np.cumsum(wts, axis=1)[:, None, :]).sum(axis=2)
mu_c = np.take_along_axis(centers, cid_c[:, :, None], axis=1)
cand2 = mu_c + rng.normal(0, s, size=(Nsp, 3, 2))
A2_, B2_, C2_ = cand2[:, 0], cand2[:, 1], cand2[:, 2]
sp2 = dict(mAB=spatial_margin(A2_, B2_), mBC=spatial_margin(B2_, C2_),
mCA=spatial_margin(C2_, A2_))
_, sid_sp2 = classify(sp2['mAB'], sp2['mBC'], sp2['mCA'])
pool2 = np.full(Nsp, np.nan)
hits2 = np.flatnonzero(sid_sp2 >= 0)
for lo in range(0, len(hits2), 5000):
idx = hits2[lo:lo+5000]
cid = (rng.random((len(idx), V, 1))
> np.cumsum(wts[idx], axis=1)[:, None, :]).sum(axis=2)
mu = np.take_along_axis(centers[idx], cid[:, :, None], axis=1)
x = mu + rng.normal(0, s, size=(len(idx), V, 2))
d2 = ((x[:, :, None, :] - cand2[idx][:, None, :, :])**2).sum(axis=3)
pool2[idx] = ((d2[:,:,0] < d2[:,:,1]) & (d2[:,:,1] < d2[:,:,2])).mean(axis=1)
sp2['pool'] = pool2
cultures['2D spatial (endogenous candidates)'] = sp2
print('simulated cultures:', ', '.join(cultures))
simulated cultures: IC, 1,001 voters, IC, 10,001 voters, IC, 8 profiles (incl. cyclic ballots), Mallows (mild consensus), IAC, 2D spatial (3 factions), 2D spatial (endogenous candidates)
Question 1: How often do manipulable states occur at all?¶
In [12]:
rows = []
for name, cu in cultures.items():
cyc, sid = classify(cu['mAB'], cu['mBC'], cu['mCA'])
rows.append({
'model': name,
'P(cycle)': f"{cyc.mean()*100:.2f}%",
'P(at a gate)': f"{np.isin(sid, list(GATE_IDS)).mean()*100:.3f}%",
'P(any of the six)': f"{(sid >= 0).mean()*100:.2f}%",
})
pd.DataFrame(rows).set_index('model')
Out[12]:
| P(cycle) | P(at a gate) | P(any of the six) | |
|---|---|---|---|
| model | |||
| IC, 1,001 voters | 8.25% | 1.393% | 9.74% |
| IC, 10,001 voters | 8.65% | 1.482% | 9.84% |
| IC, 8 profiles (incl. cyclic ballots) | 24.98% | 4.150% | 12.46% |
| Mallows (mild consensus) | 5.88% | 0.920% | 14.13% |
| IAC | 6.25% | 1.047% | 9.32% |
| 2D spatial (3 factions) | 0.11% | 0.021% | 10.30% |
| 2D spatial (endogenous candidates) | 0.15% | 0.030% | 10.10% |
In [13]:
# "Shadow win" check: among states with a Condorcet winner W, the third
# candidate F (who beats the Condorcet loser L but loses to W) makes the
# state burial-vulnerable iff F's win over L is wider than W's win over F --
# see the "Burial vulnerability" theorem. By state count this is exactly
# 18/36 = 50% of CW states. Here we measure the *simulated* rate instead.
def cw_and_vulnerable(mAB, mBC, mCA):
is_cw_A = (mAB > 0) & (mCA < 0) # A beats B and A beats C
is_cw_B = (mBC > 0) & (mAB < 0) # B beats C and B beats A
is_cw_C = (mCA > 0) & (mBC < 0) # C beats A and C beats B
has_cw = is_cw_A | is_cw_B | is_cw_C
vulnerable = np.zeros(len(mAB), dtype=bool)
# For each CW, F/L are settled by the one matchup not involving W;
# vulnerable iff F's win over L outranks W's win over F.
vulnerable[is_cw_A] = np.where(mBC[is_cw_A] > 0,
mBC[is_cw_A] > mAB[is_cw_A], # F=B, L=C
-mBC[is_cw_A] > -mCA[is_cw_A]) # F=C, L=B
vulnerable[is_cw_B] = np.where(mCA[is_cw_B] > 0,
mCA[is_cw_B] > mBC[is_cw_B], # F=C, L=A
-mCA[is_cw_B] > -mAB[is_cw_B]) # F=A, L=C
vulnerable[is_cw_C] = np.where(mAB[is_cw_C] > 0,
mAB[is_cw_C] > mCA[is_cw_C], # F=A, L=B
-mAB[is_cw_C] > -mBC[is_cw_C]) # F=B, L=A
return has_cw, vulnerable
rows = []
for name, cu in cultures.items():
has_cw, vulnerable = cw_and_vulnerable(cu['mAB'], cu['mBC'], cu['mCA'])
rows.append({
'model': name,
'P(CW state)': f"{has_cw.mean()*100:.2f}%",
'P(burial-vulnerable | CW)': f"{vulnerable[has_cw].mean()*100:.2f}%",
'state-count baseline': "50.00%",
})
pd.DataFrame(rows).set_index('model')
Out[13]:
| P(CW state) | P(burial-vulnerable | CW) | state-count baseline | |
|---|---|---|---|
| model | |||
| IC, 1,001 voters | 91.75% | 50.09% | 50.00% |
| IC, 10,001 voters | 91.35% | 50.03% | 50.00% |
| IC, 8 profiles (incl. cyclic ballots) | 75.02% | 49.93% | 50.00% |
| Mallows (mild consensus) | 94.12% | 49.96% | 50.00% |
| IAC | 93.75% | 49.97% | 50.00% |
| 2D spatial (3 factions) | 99.89% | 61.75% | 50.00% |
| 2D spatial (endogenous candidates) | 99.85% | 60.30% | 50.00% |
In [14]:
# Why do the spatial models land above the 50% baseline? Hypothesis: the
# Condorcet loser L tends to be the most "extreme" candidate (farthest from
# the electorate's center), while F is comparatively central -- so F crushes
# L, but W and F, both plausible centrists, fight over a much closer margin.
# Test this directly using the candidate/voter coordinates already simulated
# for the two spatial cultures.
electorate_center = np.einsum('nk,nkd->nd', wts, centers) # weighted mean of the 3 clusters
def cw_identify(mAB, mBC, mCA):
'''Candidate indices (0=A,1=B,2=C) for W, F, L at each row (-1 = no CW).
Mirrors cw_and_vulnerable's branches, but returns identities, not just
the vulnerability flag.'''
is_cw_A = (mAB > 0) & (mCA < 0)
is_cw_B = (mBC > 0) & (mAB < 0)
is_cw_C = (mCA > 0) & (mBC < 0)
has_cw = is_cw_A | is_cw_B | is_cw_C
w_idx = np.select([is_cw_A, is_cw_B, is_cw_C], [0, 1, 2], default=-1)
f_idx = np.full(len(mAB), -1)
l_idx = np.full(len(mAB), -1)
bc_pos, bc_neg = is_cw_A & (mBC > 0), is_cw_A & (mBC <= 0)
f_idx[bc_pos], l_idx[bc_pos] = 1, 2 # F=B, L=C
f_idx[bc_neg], l_idx[bc_neg] = 2, 1 # F=C, L=B
ca_pos, ca_neg = is_cw_B & (mCA > 0), is_cw_B & (mCA <= 0)
f_idx[ca_pos], l_idx[ca_pos] = 2, 0 # F=C, L=A
f_idx[ca_neg], l_idx[ca_neg] = 0, 2 # F=A, L=C
ab_pos, ab_neg = is_cw_C & (mAB > 0), is_cw_C & (mAB <= 0)
f_idx[ab_pos], l_idx[ab_pos] = 0, 1 # F=A, L=B
f_idx[ab_neg], l_idx[ab_neg] = 1, 0 # F=B, L=A
return has_cw, w_idx, f_idx, l_idx
def check_extremity(name, cand_arr):
cu = cultures[name]
has_cw, vulnerable = cw_and_vulnerable(cu['mAB'], cu['mBC'], cu['mCA'])
_, w_idx, f_idx, l_idx = cw_identify(cu['mAB'], cu['mBC'], cu['mCA'])
dist = np.linalg.norm(cand_arr - electorate_center[:, None, :], axis=2) # (N, 3)
idx = np.arange(len(cu['mAB']))
d_w, d_f, d_l = (dist[idx, np.clip(k, 0, 2)] for k in (w_idx, f_idx, l_idx))
m = has_cw # restrict everything below to CW states
l_farther = d_l[m] > d_f[m] # is the Condorcet loser farther out than F?
return {
'model': name,
'median dist to center: W / F / L':
f"{np.median(d_w[m]):.3f} / {np.median(d_f[m]):.3f} / {np.median(d_l[m]):.3f}",
'P(L farther than F | CW)': f"{l_farther.mean()*100:.1f}%",
'P(vulnerable | L farther)': f"{vulnerable[m][l_farther].mean()*100:.1f}%",
'P(vulnerable | L not farther)': f"{vulnerable[m][~l_farther].mean()*100:.1f}%",
}
pd.DataFrame([
check_extremity('2D spatial (3 factions)', cand),
check_extremity('2D spatial (endogenous candidates)', cand2),
]).set_index('model')
Out[14]:
| median dist to center: W / F / L | P(L farther than F | CW) | P(vulnerable | L farther) | P(vulnerable | L not farther) | |
|---|---|---|---|---|
| model | ||||
| 2D spatial (3 factions) | 0.858 / 1.434 / 2.118 | 95.1% | 64.3% | 12.5% |
| 2D spatial (endogenous candidates) | 0.566 / 0.920 / 1.385 | 91.9% | 64.5% | 12.2% |
Question 2: Could you even know you're in a manipulable state?¶
In [15]:
rows = []
for name, cu in cultures.items():
M = np.abs(np.stack([cu['mAB'], cu['mBC'], cu['mCA']], axis=1))
M.sort(axis=1) # |m1| <= |m2| <= |m3|
eps = np.minimum(M[:,0], np.minimum(M[:,1]-M[:,0], M[:,2]-M[:,1]))
_, sid = classify(cu['mAB'], cu['mBC'], cu['mCA'])
g = np.isin(sid, list(GATE_IDS)) # condition on gate states
rows.append({
'model': name,
'closest matchup': f"{np.median(M[g,0])*100:.2f}%",
'middle matchup': f"{np.median(M[g,1])*100:.2f}%",
'widest matchup': f"{np.median(M[g,2])*100:.2f}%",
'identification radius': f"{np.median(eps[g])*100:.2f}%",
'hidden below 3.5% poll noise': f"{(eps[g] < 0.035).mean()*100:.0f}%",
'hidden below 1% aggregate': f"{(eps[g] < 0.01).mean()*100:.0f}%",
})
pd.DataFrame(rows).set_index('model')
Out[15]:
| closest matchup | middle matchup | widest matchup | identification radius | hidden below 3.5% poll noise | hidden below 1% aggregate | |
|---|---|---|---|---|---|---|
| model | ||||||
| IC, 1,001 voters | 0.50% | 1.30% | 2.70% | 0.30% | 100% | 99% |
| IC, 10,001 voters | 0.13% | 0.41% | 0.85% | 0.07% | 100% | 100% |
| IC, 8 profiles (incl. cyclic ballots) | 0.90% | 2.10% | 4.10% | 0.40% | 100% | 90% |
| Mallows (mild consensus) | 0.50% | 1.30% | 2.90% | 0.30% | 100% | 99% |
| IAC | 4.35% | 12.86% | 28.85% | 2.18% | 69% | 26% |
| 2D spatial (3 factions) | 1.63% | 6.08% | 13.25% | 0.84% | 90% | 60% |
| 2D spatial (endogenous candidates) | 1.13% | 3.65% | 9.38% | 0.53% | 97% | 74% |
In [16]:
def bracket(cu, sid_target, state):
_, sid = classify(cu['mAB'], cu['mBC'], cu['mCA'])
mask = (sid == sid_target)
M = np.abs(np.stack([cu['mAB'], cu['mBC'], cu['mCA']], axis=1))[mask]
M.sort(axis=1)
labels = [f"{w}→{l}" for w, l in state]
p1, p2, p3 = (np.median(M[:, k])*100 for k in range(3))
delta = manip_delta(sid, cu['mAB'], cu['mBC'])[mask]
manip_cost = np.median(delta)*100 if mask.any() else float('nan')
pool_pct = np.median(cu['pool'][mask])*100 if mask.any() else float('nan')
bracket_str = f"⟨{labels[0]} ({p1:.2f}%) | {labels[1]} ({p2:.2f}%) | {labels[2]} ({p3:.2f}%)⟩"
return bracket_str, pool_pct, manip_cost
rows = []
for name, cu in cultures.items():
for sid, (label, state, depth) in enumerate(named):
bracket_str, pool_pct, manip_cost = bracket(cu, sid, state)
rows.append({
'model': name,
'state': label,
'median margins': bracket_str,
'median manipulation cost': f"{manip_cost:.2f}%",
'median A≻B≻C pool': f"{pool_pct:.2f}%",
})
pd.DataFrame(rows).set_index(['model', 'state'])
Out[16]:
| median margins | median manipulation cost | median A≻B≻C pool | ||
|---|---|---|---|---|
| model | state | |||
| IC, 1,001 voters | start1 | ⟨B→A (0.90%) | A→C (2.50%) | B→C (4.50%)⟩ | 2.80% | 17.48% |
| start2 | ⟨B→A (0.70%) | B→C (2.50%) | A→C (4.10%)⟩ | 1.70% | 17.08% | |
| start3 | ⟨B→C (0.70%) | B→A (1.70%) | A→C (3.30%)⟩ | 1.30% | 16.38% | |
| G1 | ⟨C→B (0.50%) | B→A (1.30%) | A→C (2.70%)⟩ | 0.30% | 16.18% | |
| betrayal start | ⟨B→C (0.50%) | C→A (1.30%) | A→B (2.90%)⟩ | 1.10% | 17.58% | |
| G2 | ⟨B→C (0.50%) | A→B (1.30%) | C→A (2.70%)⟩ | 0.30% | 17.18% | |
| IC, 10,001 voters | start1 | ⟨B→A (0.31%) | A→C (0.81%) | B→C (1.39%)⟩ | 0.88% | 16.92% |
| start2 | ⟨B→A (0.27%) | B→C (0.75%) | A→C (1.27%)⟩ | 0.53% | 16.78% | |
| start3 | ⟨B→C (0.25%) | B→A (0.55%) | A→C (1.01%)⟩ | 0.41% | 16.60% | |
| G1 | ⟨C→B (0.13%) | B→A (0.41%) | A→C (0.85%)⟩ | 0.10% | 16.51% | |
| betrayal start | ⟨B→C (0.13%) | C→A (0.41%) | A→B (0.85%)⟩ | 0.32% | 16.94% | |
| G2 | ⟨B→C (0.13%) | A→B (0.41%) | C→A (0.87%)⟩ | 0.10% | 16.82% | |
| IC, 8 profiles (incl. cyclic ballots) | start1 | ⟨B→A (0.90%) | A→C (2.10%) | B→C (4.10%)⟩ | 2.60% | 13.19% |
| start2 | ⟨B→A (0.90%) | B→C (2.10%) | A→C (4.10%)⟩ | 1.60% | 13.19% | |
| start3 | ⟨B→C (0.90%) | B→A (2.10%) | A→C (4.10%)⟩ | 1.50% | 12.89% | |
| G1 | ⟨C→B (0.90%) | B→A (2.10%) | A→C (4.10%)⟩ | 0.50% | 12.59% | |
| betrayal start | ⟨B→C (0.90%) | C→A (2.10%) | A→B (4.10%)⟩ | 1.50% | 12.89% | |
| G2 | ⟨B→C (0.90%) | A→B (2.30%) | C→A (4.10%)⟩ | 0.50% | 12.39% | |
| Mallows (mild consensus) | start1 | ⟨B→A (1.10%) | A→C (3.10%) | B→C (5.29%)⟩ | 3.30% | 17.68% |
| start2 | ⟨B→A (0.90%) | B→C (3.10%) | A→C (4.90%)⟩ | 2.00% | 17.18% | |
| start3 | ⟨B→C (0.90%) | B→A (1.90%) | A→C (3.50%)⟩ | 1.40% | 16.38% | |
| G1 | ⟨C→B (0.50%) | B→A (1.30%) | A→C (2.90%)⟩ | 0.30% | 16.18% | |
| betrayal start | ⟨B→C (0.50%) | C→A (1.30%) | A→B (3.10%)⟩ | 1.20% | 17.58% | |
| G2 | ⟨B→C (0.50%) | A→B (1.30%) | C→A (2.70%)⟩ | 0.35% | 17.18% | |
| IAC | start1 | ⟨B→A (13.02%) | A→C (31.27%) | B→C (50.14%)⟩ | 32.45% | 24.95% |
| start2 | ⟨B→A (13.04%) | B→C (31.53%) | A→C (50.18%)⟩ | 23.06% | 15.69% | |
| start3 | ⟨B→C (12.92%) | B→A (22.74%) | A→C (40.75%)⟩ | 17.71% | 6.36% | |
| G1 | ⟨C→B (4.38%) | B→A (12.88%) | A→C (28.75%)⟩ | 3.20% | 6.79% | |
| betrayal start | ⟨B→C (4.39%) | C→A (13.08%) | A→B (29.18%)⟩ | 11.39% | 26.55% | |
| G2 | ⟨B→C (4.30%) | A→B (12.84%) | C→A (28.97%)⟩ | 3.24% | 17.77% | |
| 2D spatial (3 factions) | start1 | ⟨B→A (26.39%) | A→C (63.35%) | B→C (86.36%)⟩ | 55.22% | 31.00% |
| start2 | ⟨B→A (32.86%) | B→C (71.24%) | A→C (89.69%)⟩ | 51.56% | 13.00% | |
| start3 | ⟨B→C (16.65%) | B→A (19.89%) | A→C (31.34%)⟩ | 18.00% | 3.00% | |
| G1 | ⟨C→B (2.31%) | B→A (5.68%) | A→C (12.30%)⟩ | 1.62% | 9.20% | |
| betrayal start | ⟨B→C (1.30%) | C→A (5.88%) | A→B (10.49%)⟩ | 4.29% | 20.60% | |
| G2 | ⟨B→C (1.44%) | A→B (6.51%) | C→A (14.16%)⟩ | 2.32% | 16.20% | |
| 2D spatial (endogenous candidates) | start1 | ⟨B→A (15.41%) | A→C (39.47%) | B→C (58.42%)⟩ | 38.04% | 35.80% |
| start2 | ⟨B→A (17.36%) | B→C (42.68%) | A→C (66.26%)⟩ | 30.90% | 11.00% | |
| start3 | ⟨B→C (10.53%) | B→A (13.70%) | A→C (23.33%)⟩ | 12.08% | 2.60% | |
| G1 | ⟨C→B (1.12%) | B→A (3.72%) | A→C (11.46%)⟩ | 1.16% | 7.10% | |
| betrayal start | ⟨B→C (1.78%) | C→A (4.17%) | A→B (10.96%)⟩ | 4.88% | 20.80% | |
| G2 | ⟨B→C (1.18%) | A→B (3.65%) | C→A (8.67%)⟩ | 0.92% | 13.40% |
In [17]:
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
%matplotlib inline
plt.style.use('dark_background')
fig, ax = plt.subplots(figsize=(8, 4.6), dpi=110)
fig.patch.set_facecolor('#111318'); ax.set_facecolor('#111318')
colors = ['#7aa2f7', '#7dcfff', '#e0af68', '#9ece6a', '#bb9af7', '#f7768e', '#73daca']
for (name, cu), col in zip(cultures.items(), colors):
M = np.abs(np.stack([cu['mAB'], cu['mBC'], cu['mCA']], axis=1)); M.sort(axis=1)
eps = np.minimum(M[:,0], np.minimum(M[:,1]-M[:,0], M[:,2]-M[:,1]))
_, sid = classify(cu['mAB'], cu['mBC'], cu['mCA'])
e = np.sort(eps[np.isin(sid, list(GATE_IDS))])
ax.plot(e*100, np.arange(1, len(e)+1)/len(e)*100, color=col, lw=1.6, label=name)
ax.axvline(3.5, color='#c0caf5', ls='--', lw=1)
ax.text(3.8, 8, 'real-world poll error (3.5%)', color='#c0caf5', fontsize=8)
ax.axvline(1.0, color='#565f89', ls=':', lw=1)
ax.text(1.08, 45, 'optimistic aggregate (1%)', color='#565f89', fontsize=8, rotation=90)
ax.set_xscale('log')
ax.set_xlabel('identification radius ε (% margin error that changes the state, log scale)')
ax.set_ylabel('share of gate states with radius ≤ ε')
ax.set_title('When a gate occurs, can polling even detect it?', fontsize=11)
ax.legend(frameon=False, fontsize=8, loc='upper left')
ax.grid(alpha=0.15)
plt.tight_layout(); plt.show()
Question 3: How many voters would have to lie?¶
In [18]:
rows = []
for name, cu in cultures.items():
_, sid = classify(cu['mAB'], cu['mBC'], cu['mCA'])
delta = manip_delta(sid, cu['mAB'], cu['mBC'])
six, gates = (sid >= 0), np.isin(sid, list(GATE_IDS))
ratio = np.divide(delta, cu['pool'],
out=np.full_like(delta, np.inf), where=cu['pool'] > 0)
rows.append({
'model': name,
'median cost, at gates': f"{np.median(delta[gates])*100:.2f}%",
'median cost, any of six': f"{np.median(delta[six])*100:.1f}%",
'median share of coalition needed': f"{np.median(ratio[six])*100:.0f}%",
'P(coalition big enough)': f"{(ratio[six] <= 1).mean()*100:.0f}%",
})
pd.DataFrame(rows).set_index('model')
Out[18]:
| median cost, at gates | median cost, any of six | median share of coalition needed | P(coalition big enough) | |
|---|---|---|---|---|
| model | ||||
| IC, 1,001 voters | 0.30% | 1.7% | 10% | 100% |
| IC, 10,001 voters | 0.10% | 0.5% | 3% | 100% |
| IC, 8 profiles (incl. cyclic ballots) | 0.50% | 1.3% | 10% | 100% |
| Mallows (mild consensus) | 0.30% | 2.4% | 14% | 100% |
| IAC | 3.22% | 22.2% | 120% | 41% |
| 2D spatial (3 factions) | 1.76% | 53.3% | 238% | 10% |
| 2D spatial (endogenous candidates) | 0.96% | 34.4% | 154% | 27% |
In [19]:
fig, ax = plt.subplots(figsize=(8, 4.6), dpi=110)
fig.patch.set_facecolor('#111318'); ax.set_facecolor('#111318')
for (name, cu), col in zip(cultures.items(), colors):
_, sid = classify(cu['mAB'], cu['mBC'], cu['mCA'])
delta = manip_delta(sid, cu['mAB'], cu['mBC'])
six = (sid >= 0)
# ratio = share of the *coalition* (not the electorate) that must lie;
# a pool of 0 makes the manipulation impossible outright (ratio = inf).
ratio = np.divide(delta, cu['pool'],
out=np.full_like(delta, np.inf), where=cu['pool'] > 0)
r = np.sort(ratio[six])
n = len(r)
finite = np.isfinite(r)
# CDF denominator is the full count `n`, so curves that hit an infeasible
# (infinite-ratio) state simply stop short of 100% on the y-axis.
ax.plot(r[finite]*100, (np.flatnonzero(finite)+1) * 100 / n, color=col, lw=1.6, label=name)
ax.axvline(100, color='#c0caf5', ls='--', lw=1.3)
ax.text(112, 6, 'needs the entire coalition\n(beyond this: impossible)', color='#c0caf5', fontsize=8)
ax.set_xscale('log')
ax.set_xlim(1e-2, 3000) # a rare tail of extreme outliers (tiny pools) runs off-chart
ax.set_xlabel(r'share of the coalition that must lie, $\delta^*/\mathrm{pool}$ (%, log scale)')
ax.set_ylabel('share of manipulable states costing ≤ that share')
ax.set_title('Given a manipulation exists, what fraction of the coalition must lie?', fontsize=11)
ax.legend(frameon=False, fontsize=8, loc='upper left')
ax.grid(alpha=0.15)
plt.tight_layout(); plt.show()