diff --git a/.gitignore b/.gitignore index 5231148..6cdd4bb 100644 --- a/.gitignore +++ b/.gitignore @@ -231,3 +231,4 @@ _autosummary/ # Build-time copy of examples into docs (see conf.py prepare_examples_doc) docs/examples/README.md docs/examples/results/ +examples/results/ diff --git a/examples/README.md b/examples/README.md index 9e38b07..a09cc73 100644 --- a/examples/README.md +++ b/examples/README.md @@ -306,6 +306,38 @@ Source: [distributions_by_dispersion.py](distributions_by_dispersion.py) Source: [distributions_by_method_2D.py](distributions_by_method_2D.py) +## Center-squeeze collapse animations (2D) + +Animated demonstrations of the center-squeeze effect in a two-dimensional +spatial model. Voters and candidates are both normally distributed (candidate +spread half the voter spread), and the scripts sample random elections until +they find a "core collapse" example, then animate the elimination rounds: +each round shows ballots transferring between candidates, first-choice tallies, +average favorability (mean normalized utility), and head-to-head wins. + +- **IRV** (`collapse_finder_2d_irv.py`): keeps elections where vote-splitting + makes IRV eliminate the verified Condorcet winner nearest the electorate's + center first and work outward, so only the two farthest candidates survive + to the final round. +- **Total Vote Runoff / Baldwin** (`collapse_finder_2d_tvr.py`): eliminates the + lowest Borda-count candidate each round, so the least-representative + candidates drop first and support converges inward. Baldwin and Total Vote + Runoff satisfy the Condorcet criterion, so the script keeps elections where + the geometric-center candidate is verified as the Condorcet winner from the + pairwise matrix and is elected by Baldwin. +- **Both** (`collapse_finder_2d_both.py`): finds a single election satisfying + both verified criteria above: IRV squeezes out the Condorcet winner while + Baldwin correctly elects it. It renders both animations side by side from + the same voters and candidates. + +Running a script searches for a qualifying election and writes its frames and +GIF under `examples/results/` (generated output is git-ignored). Run from the +repository root, e.g. `python -m examples.collapse_finder_2d_irv`. + +Sources: [collapse_finder_2d_irv.py](collapse_finder_2d_irv.py), +[collapse_finder_2d_tvr.py](collapse_finder_2d_tvr.py), +[collapse_finder_2d_both.py](collapse_finder_2d_both.py) + ## Tomlinson 2023 Kiran Tomlinson, Johan Ugander, Jon Kleinberg (2023) [Moderation in instant runoff voting](https://arxiv.org/abs/2303.09734) diff --git a/examples/collapse_2d_shared.py b/examples/collapse_2d_shared.py new file mode 100644 index 0000000..4665e10 --- /dev/null +++ b/examples/collapse_2d_shared.py @@ -0,0 +1,407 @@ +"""Geometry, palette, and theme helpers for the two-dimensional examples.""" + +import importlib +from pathlib import Path + +import matplotlib.colors as mcolors +import matplotlib.patheffects as PathEffects +import matplotlib.pyplot as plt +import numpy as np +from matplotlib.collections import LineCollection +from scipy.spatial import Voronoi + +RESULTS_DIR = Path(__file__).resolve().parent / 'results' +KEY_FRAME_MS = 3000 +TRANSITION_TOTAL_MS = 3000 + +PALETTE_OPTIONS = { + 'palettable.cartocolors.qualitative': [ + 'Antique_10', 'Bold_10', 'Pastel_10', 'Prism_10', 'Safe_10', 'Vivid_10', + ], + 'palettable.colorbrewer.qualitative': [ + 'Set3_12', 'Set2_8', 'Set1_9', 'Paired_12', 'Dark2_8', 'Accent_8', + ], + 'palettable.tableau': [ + 'ColorBlind_10', 'GreenOrange_12', 'TableauLight_10', 'TableauMedium_10', + 'Tableau_10', 'Tableau_20', + ], + 'colorcet': ['glasbey_light', 'glasbey_dark'], +} + + +def get_palette_colors(name): + """Load a named palette as a list of Matplotlib-compatible colors.""" + for module_path, names in PALETTE_OPTIONS.items(): + if name in names: + break + else: + raise KeyError(name) + + module = importlib.import_module(module_path) + palette = getattr(module, name) + if module_path == 'colorcet': + return list(palette) + return list(palette.mpl_colors) + + +def prepare_palette_and_labels(palette_name, n_cands, dark_background): + """Load, optionally adjust, and trim a palette and create labels.""" + colors = get_palette_colors(palette_name) + if not dark_background and palette_name == 'Set1_9' and len(colors) > 5: + colors.pop(5) + if n_cands > len(colors): + raise ValueError( + f'n_cands={n_cands} exceeds palette "{palette_name}" size ' + f'({len(colors)}). Use fewer candidates or a larger palette.' + ) + return colors[:n_cands], 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'[:n_cands] + + +def _color_to_rgb(color): + """Normalize a color to an RGB tuple in the [0, 1] range.""" + return mcolors.to_rgb(color) + + +def remove_grays(colors, min_saturation=0.12): + """Drop colors with saturation below ``min_saturation``.""" + filtered = [] + for color in colors: + rgb = np.array(_color_to_rgb(color)).reshape(1, 3) + if mcolors.rgb_to_hsv(rgb)[0, 1] >= min_saturation: + filtered.append(color) + return filtered, len(filtered) + + +def get_theme(dark_background): + """Return colors for the dark or light rendering theme.""" + if dark_background: + return ( + 'black', + 'white', + 'white', + 'black', + 'black', + 'white', + (0.98, 0.98, 0.98), + '0.15', + ) + return ( + 'white', + 'black', + 'gray', + 'white', + 'white', + 'black', + (0.12, 0.12, 0.12), + '0.88', + ) + + +def transition_step_ms(n_transfer): + """Return the duration of each non-final transfer frame in milliseconds.""" + return TRANSITION_TOTAL_MS // max(1, n_transfer - 1) if n_transfer > 1 else 0 + + +def candidate_name(candidate_index): + """Convert a zero-based candidate index to an alphabetical label.""" + return chr(65 + candidate_index) + + +def ceildiv(a, b): + """Return the ceiling of integer division for positive integers.""" + return -(-a // b) + + +def count_wins(matrix): + """Count strict pairwise wins for every candidate in a comparison matrix.""" + n_cands = matrix.shape[0] + return [ + sum(matrix[i, j] > matrix[j, i] for j in range(n_cands)) + for i in range(n_cands) + ] + + +def plot_wins(ax, wins, colors, labels, edgecolor='black', gap=0.15): + """Plot strict head-to-head wins as stacked square blocks.""" + n_cands = len(wins) + block = 1.0 - 2 * gap + max_wins = max(wins) if wins else 0 + for candidate in range(n_cands): + for index in range(int(wins[candidate])): + ax.bar( + candidate, + block, + bottom=index + gap, + width=block, + color=colors[candidate], + edgecolor=edgecolor, + linewidth=1, + ) + ax.set_xticks(range(n_cands)) + ax.set_xticklabels(list(labels)) + ax.set_xlim(-0.5, n_cands - 0.5) + ax.set_ylim(0, max_wins if max_wins > 0 else 1) + ax.set_aspect('equal') + ax.yaxis.set_major_locator(plt.MaxNLocator(integer=True)) + ax.set_ylabel('') + + +def plot_wins_with_title(ax, wins, colors, labels, fg, gap=0.1): + """Plot head-to-head wins and add the standard panel title.""" + plot_wins(ax, wins, colors, labels, edgecolor=fg, gap=gap) + ax.text( + 0.5, + 1.04, + 'Head-to-head wins', + transform=ax.transAxes, + ha='center', + va='center', + color=fg, + ) + + +def plot_favorability_bar(ax, favorability_pct, labels, colors, fg, grid): + """Plot mean normalized utility as an average favorability percentage.""" + bars = ax.bar( + range(len(labels)), + favorability_pct, + tick_label=list(labels), + color=colors, + ) + for rect in bars: + height = rect.get_height() + if height > 0: + ax.annotate( + f'{height:.0f}', + xy=(rect.get_x() + rect.get_width() / 2, height), + xytext=(0, 3), + textcoords='offset points', + ha='center', + va='bottom', + color=fg, + ) + ax.set_ylim(0, 100) + ax.set_ylabel('Mean utility [%]') + ax.grid(True, alpha=0.25, axis='y', color=grid) + ax.set_axisbelow(True) + ax.text( + 0.5, + 1.04, + 'Average favorability', + transform=ax.transAxes, + ha='center', + va='center', + color=fg, + ) + + +def voronoi_plot_2d_axes(ax, points, line_color='white', line_alpha=0.45): + """Draw a Voronoi diagram on an axis without changing its limits.""" + points = np.asarray(points) + if len(points) < 2: + return + if len(points) == 2: + first, second = points + delta = second - first + length = np.linalg.norm(delta) + if length == 0: + return + midpoint = (first + second) / 2 + direction = np.array([-delta[1], delta[0]]) / length + span = max(np.ptp(ax.get_xlim()), np.ptp(ax.get_ylim()), 1.0) * 2 + endpoints = np.array([ + midpoint - span * direction, + midpoint + span * direction, + ]) + ax.plot( + endpoints[:, 0], + endpoints[:, 1], + ':', + color=line_color, + alpha=line_alpha, + ) + return + + voronoi = Voronoi(points) + center = points.mean(axis=0) + xlim = ax.get_xlim() + ylim = ax.get_ylim() + span = max(np.ptp(xlim), np.ptp(ylim)) + finite_segments = [] + infinite_segments = [] + for point_indices, vertices in zip( + voronoi.ridge_points, voronoi.ridge_vertices + ): + vertices = np.asarray(vertices) + if np.all(vertices >= 0): + finite_segments.append(voronoi.vertices[vertices]) + continue + finite_vertex = vertices[vertices >= 0] + if not len(finite_vertex): + continue + tangent = voronoi.points[point_indices[1]] - voronoi.points[point_indices[0]] + tangent /= np.linalg.norm(tangent) + normal = np.array([-tangent[1], tangent[0]]) + midpoint = voronoi.points[point_indices].mean(axis=0) + direction = np.sign(np.dot(midpoint - center, normal)) * normal + far = voronoi.vertices[finite_vertex[0]] + direction * 2 * span + infinite_segments.append([voronoi.vertices[finite_vertex[0]], far]) + + for segments in (finite_segments, infinite_segments): + if segments: + ax.add_collection( + LineCollection( + segments, + colors=line_color, + lw=1.5, + alpha=line_alpha, + linestyle=':', + zorder=0, + ) + ) + ax.set_xlim(xlim) + ax.set_ylim(ylim) + + +def sort_candidates_bell_curve(candidates): + """Order left candidates outward-in, center, then right candidates in-out.""" + candidates = np.asarray(candidates) + distances = np.linalg.norm(candidates, axis=1) + center_mask = np.isclose(distances, 0) + left_mask = candidates[:, 0] < 0 + right_mask = ~left_mask & ~center_mask + center_indices = np.flatnonzero(center_mask) + left_indices = np.flatnonzero(left_mask) + right_indices = np.flatnonzero(right_mask) + left_sorted = left_indices[np.argsort(distances[left_indices])[::-1]] + right_sorted = right_indices[np.argsort(distances[right_indices])] + return candidates[ + np.concatenate([left_sorted, center_indices, right_sorted]) + ] + + +def setup_scatter_axis_sigma(ax, voters): + """Set square limits and visible ticks in units of voter-distribution σ.""" + ax.grid(False) + ax.set_axisbelow(False) + sigma = float(np.std(voters)) + limit = 1.5 * sigma + ax.set_xlim(-limit, limit) + ax.set_ylim(-limit, limit) + ax.axis('square') + tick_positions = [-sigma, 0, sigma] + tick_labels = ['−σ', '0', 'σ'] + ax.set_xticks(tick_positions) + ax.set_yticks(tick_positions) + ax.set_xticklabels(tick_labels) + ax.set_yticklabels(tick_labels) + + +def create_frame_scaffold( + voters, + candidates, + ballots, + favorability_pct, + wins, + colors, + labels, + eliminated=None, + dark_background=True, +): + """Create the common four-panel figure used by collapse animations. + + The returned ``axes['middle']`` is intentionally left empty for the caller + to populate with method-specific vote or score data. + """ + eliminated = set() if eliminated is None else set(eliminated) + n_cands = len(candidates) + active = [candidate for candidate in range(n_cands) if candidate not in eliminated] + active_colors = [ + colors[candidate] if candidate not in eliminated else (0.5, 0.5, 0.5) + for candidate in range(n_cands) + ] + ( + bg, + fg, + grid, + stroke_fg, + legend_bg, + legend_fg, + voronoi_color, + dead_zone_color, + ) = get_theme(dark_background) + + fig = plt.figure(figsize=(9, 7.5), facecolor=bg) + axes = { + 'scatter': plt.subplot2grid((6, 3), (0, 0), colspan=2, rowspan=6), + 'middle': plt.subplot2grid((6, 3), (0, 2), rowspan=2), + 'favorability': plt.subplot2grid((6, 3), (2, 2), rowspan=2), + 'wins': plt.subplot2grid((6, 3), (4, 2), rowspan=2), + } + for axis in axes.values(): + axis.set_facecolor(bg) + axis.tick_params(colors=fg) + axis.xaxis.label.set_color(fg) + axis.yaxis.label.set_color(fg) + for spine in axis.spines.values(): + spine.set_color(fg) + + voters_kwargs = {'marker': '.', 'alpha': 0.25, 's': 12} + candidates_kwargs = {'marker': 'o', 's': 30, 'edgecolors': fg} + axes['scatter'].scatter([], [], color=fg, **voters_kwargs, label='Voters') + axes['scatter'].scatter( + [], [], color=fg, **candidates_kwargs, label='Candidates' + ) + axes['scatter'].legend( + loc='lower right', + numpoints=1, + fontsize='small', + labelcolor=legend_fg, + facecolor=legend_bg, + edgecolor=legend_fg, + ) + setup_scatter_axis_sigma(axes['scatter'], voters) + voronoi_plot_2d_axes( + axes['scatter'], + candidates[active], + line_color=voronoi_color, + line_alpha=0.45, + ) + + path_effects = [PathEffects.withStroke(linewidth=3, foreground=stroke_fg)] + for candidate in range(n_cands): + candidate_voters = voters[ballots == candidate] + if len(candidate_voters): + axes['scatter'].scatter( + candidate_voters[:, 0], + candidate_voters[:, 1], + color=active_colors[candidate], + **voters_kwargs, + ) + if active: + axes['scatter'].scatter( + candidates[active, 0], + candidates[active, 1], + color=[active_colors[candidate] for candidate in active], + **candidates_kwargs, + ) + for candidate in active: + axes['scatter'].annotate( + labels[candidate], + xy=candidates[candidate], + xytext=(0, -15), + textcoords='offset points', + path_effects=path_effects, + color=fg, + ) + + plot_favorability_bar( + axes['favorability'], + favorability_pct, + labels, + active_colors, + fg, + grid, + ) + plot_wins_with_title(axes['wins'], wins, active_colors, labels, fg) + return fig, axes, active_colors, (bg, fg, grid, dead_zone_color) diff --git a/examples/collapse_finder_2d_both.py b/examples/collapse_finder_2d_both.py new file mode 100644 index 0000000..d1d385f --- /dev/null +++ b/examples/collapse_finder_2d_both.py @@ -0,0 +1,100 @@ +"""Find and render one election showing IRV/Baldwin Condorcet contrast.""" + +from datetime import datetime +from pathlib import Path + +import numpy as np + +from elsim.elections import normal_electorate, normed_dist_utilities +from elsim.strategies import honest_rankings + +from examples.collapse_2d_shared import RESULTS_DIR, sort_candidates_bell_curve +from examples.collapse_finder_2d_irv import ( + run_irv_animation, + simulate_irv_rounds, +) +from examples.collapse_finder_2d_tvr import ( + run_tvr_animation, + simulate_tvr_rounds, +) + + +def election_to_traces(voters, candidates): + """Compute honest rankings and both method traces for one election.""" + utilities = normed_dist_utilities(voters, candidates) + rankings = np.asarray(honest_rankings(utilities)) + irv_trace = simulate_irv_rounds(rankings, candidates) + tvr_trace = simulate_tvr_rounds(rankings, candidates) + return rankings, irv_trace, tvr_trace + + +def find_both_election(n_voters, n_cands, max_trials, disp=1.0): + """Sample one election satisfying both verified method criteria.""" + for trial in range(1, max_trials + 1): + voters, candidates = normal_electorate( + n_voters, + n_cands, + dims=2, + disp=disp, + ) + candidates[0] = 0.0 + candidates = sort_candidates_bell_curve(candidates) + rankings, irv_trace, tvr_trace = election_to_traces(voters, candidates) + if irv_trace is not None and tvr_trace is not None: + return trial, voters, candidates, rankings, irv_trace, tvr_trace + return None + + +if __name__ == '__main__': + n_voters = 5000 + n_cands = 9 + max_trials = 100_000 + frames_per_transfer = 60 + disp = 0.5 + palette_name = 'Bold_10' + dark_background = True + + result = find_both_election( + n_voters, + n_cands, + max_trials, + disp=disp, + ) + if result is None: + raise RuntimeError( + 'No election found where IRV eliminates the Condorcet winner ' + 'center-outward and Baldwin elects it.' + ) + + trial, voters, candidates, rankings, irv_trace, tvr_trace = result + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + output_dir = RESULTS_DIR / ( + f'collapse_2d_both_{timestamp}_nc{n_cands}_nv{n_voters}' + ) + output_dir.mkdir(parents=True, exist_ok=True) + np.savez(output_dir / 'positions.npz', voters=voters, candidates=candidates) + + print(f'Found shared election on trial {trial}.') + print('Rendering IRV animation...') + run_irv_animation( + voters, + candidates, + rankings, + irv_trace, + output_dir / 'irv', + palette_name=palette_name, + frames_per_transfer=frames_per_transfer, + dark_background=dark_background, + ) + print('Rendering Baldwin animation...') + run_tvr_animation( + voters, + candidates, + rankings, + tvr_trace, + output_dir / 'tvr', + palette_name=palette_name, + frames_per_transfer=frames_per_transfer, + dark_background=dark_background, + ) + print(f'Saved both animations to {output_dir.resolve()}') diff --git a/examples/collapse_finder_2d_irv.py b/examples/collapse_finder_2d_irv.py new file mode 100644 index 0000000..1258c46 --- /dev/null +++ b/examples/collapse_finder_2d_irv.py @@ -0,0 +1,371 @@ +"""Find and animate a two-dimensional IRV center-outward collapse.""" + +from datetime import datetime +from pathlib import Path + +import matplotlib + +matplotlib.use('Agg') +import matplotlib.pyplot as plt +import numpy as np +from PIL import Image + +from elsim.elections import normal_electorate, normed_dist_utilities +from elsim.methods import condorcet_from_matrix, ranked_election_to_matrix +from elsim.methods.irv import IRVResult, irv_rounds +from elsim.strategies import honest_rankings + +from examples.collapse_2d_shared import ( + KEY_FRAME_MS, + RESULTS_DIR, + ceildiv, + candidate_name, + create_frame_scaffold, + get_palette_colors, + prepare_palette_and_labels, + sort_candidates_bell_curve, + transition_step_ms, +) +from examples.collapse_2d_shared import count_wins + + +def validate_center_outward(result, candidates, election): + """Validate center-outward elimination of the verified Condorcet winner.""" + if result is None: + return None + if len(result.initially_eliminated): + return None + + condorcet_winner = condorcet_from_matrix( + ranked_election_to_matrix(election) + ) + if condorcet_winner is None or not result.rounds: + return None + + candidates = np.asarray(candidates) + distances = np.linalg.norm(candidates, axis=1) + active = set(range(len(candidates))) + if result.rounds[0].eliminated != condorcet_winner: + return None + for round_ in result.rounds: + if round_.eliminated != min( + active, key=lambda candidate: (distances[candidate], candidate) + ): + return None + active.remove(round_.eliminated) + + expected_final = set(np.argsort(distances)[-2:]) + if set(result.active_candidates) != expected_final: + return None + if len(result.active_candidates) != 2: + return None + return result + + +def simulate_irv_rounds(election, candidates): + """Run IRV and verify it eliminates the Condorcet winner first.""" + return validate_center_outward( + irv_rounds(election, tiebreaker=None, stop_at=2), + candidates, + election, + ) + + +def find_center_outward_election(n_voters, n_cands, max_trials, disp=1.0): + """Sample elections where IRV squeezes out the Condorcet winner.""" + for trial in range(1, max_trials + 1): + voters, candidates = normal_electorate( + n_voters, + n_cands, + dims=2, + disp=disp, + ) + candidates[0] = 0.0 + candidates = sort_candidates_bell_curve(candidates) + utilities = normed_dist_utilities(voters, candidates) + rankings = np.asarray(honest_rankings(utilities)) + trace = simulate_irv_rounds(rankings, candidates) + if trace is not None: + return trial, voters, candidates, rankings, trace + return None + + +def _plot_votes_panel(axis, tallies, labels, colors, fg, grid, title): + """Plot first-choice vote percentages in the scaffold's middle panel.""" + n_voters = tallies.sum() + bars = axis.bar( + range(len(labels)), + tallies / n_voters * 100 if n_voters else tallies, + tick_label=list(labels), + color=colors, + ) + for rect in bars: + height = rect.get_height() + if height > 0: + axis.annotate( + f'{height:.0f}', + xy=(rect.get_x() + rect.get_width() / 2, height), + xytext=(0, 3), + textcoords='offset points', + ha='center', + va='bottom', + color=fg, + ) + axis.set_ylim(0, 100) + axis.set_ylabel('Votes [%]') + axis.grid(True, alpha=0.25, axis='y', color=grid) + axis.set_axisbelow(True) + axis.text( + 0.5, + 1.04, + title, + transform=axis.transAxes, + ha='center', + va='center', + color=fg, + ) + + +def render_frame( + voters, + candidates, + ballots, + tallies, + favorability_pct, + wins, + colors, + labels, + frame_title, + output_path, + eliminated=None, + dark_background=True, +): + """Render one IRV frame using the shared four-panel scaffold.""" + fig, axes, active_colors, theme = create_frame_scaffold( + voters, + candidates, + ballots, + favorability_pct, + wins, + colors, + labels, + eliminated=eliminated, + dark_background=dark_background, + ) + _, fg, grid, _ = theme + _plot_votes_panel( + axes['middle'], + tallies, + labels, + active_colors, + fg, + grid, + frame_title, + ) + fig.tight_layout() + fig.savefig(output_path, facecolor=theme[0], edgecolor='none') + plt.close(fig) + + +def _clear_png_frames(output_dir): + """Remove only numbered PNG frames from a reusable animation directory.""" + for path in Path(output_dir).glob('[0-9][0-9][0-9][0-9].png'): + path.unlink() + + +def run_irv_animation( + voters, + candidates, + rankings, + trace: IRVResult, + output_dir, + *, + palette_name='Bold_10', + frames_per_transfer=60, + dark_background=True, + seed=0, +): + """Render a traced IRV collapse and save its frames and GIF.""" + voters = np.asarray(voters) + candidates = np.asarray(candidates) + rankings = np.asarray(rankings) + n_voters = len(voters) + n_cands = len(candidates) + if rankings.shape != (n_voters, n_cands): + raise ValueError( + f'Rankings shape {rankings.shape} does not match ' + f'{n_voters} voters and {n_cands} candidates.' + ) + + output_dir = Path(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + _clear_png_frames(output_dir) + colors, labels = prepare_palette_and_labels( + palette_name, + n_cands, + dark_background, + ) + utilities = normed_dist_utilities(voters, candidates) + favorability_pct = utilities.mean(axis=0) * 100 + wins = count_wins(ranked_election_to_matrix(rankings)) + choices = rankings[:, 0].copy() + initially_eliminated = set(map(int, trace.initially_eliminated)) + eliminated = set(initially_eliminated) + durations = [] + frame = 0 + + initial_tallies = np.bincount(choices, minlength=n_cands) + render_frame( + voters, + candidates, + choices, + initial_tallies, + favorability_pct, + wins, + colors, + labels, + 'IRV start', + output_dir / f'{frame:04d}.png', + eliminated=eliminated, + dark_background=dark_background, + ) + durations.append(KEY_FRAME_MS) + frame += 1 + + rng = np.random.default_rng(seed) + for round_index, round_ in enumerate(trace.rounds, start=1): + loser = int(round_.eliminated) + eliminated_now = eliminated | {loser} + render_frame( + voters, + candidates, + choices, + round_.tallies_before, + favorability_pct, + wins, + colors, + labels, + f'Round {round_index}: eliminate {candidate_name(loser)}', + output_dir / f'{frame:04d}.png', + eliminated=eliminated_now, + dark_background=dark_background, + ) + durations.append(KEY_FRAME_MS) + frame += 1 + + transferred_voters = round_.transferred_voters.copy() + transferred_to = round_.transferred_to.copy() + order = rng.permutation(len(transferred_voters)) + per_frame = max(1, ceildiv(len(order), frames_per_transfer)) + tallies = round_.tallies_before.copy() + for step in range(frames_per_transfer): + start = step * per_frame + stop = min(start + per_frame, len(order)) + for position in order[start:stop]: + voter = transferred_voters[position] + target = transferred_to[position] + tallies[loser] -= 1 + tallies[target] += 1 + choices[voter] = target + if step == frames_per_transfer - 1: + tallies = round_.tallies_after.copy() + choices[transferred_voters] = transferred_to + render_frame( + voters, + candidates, + choices, + tallies, + favorability_pct, + wins, + colors, + labels, + f'Round {round_index}: eliminate {candidate_name(loser)}', + output_dir / f'{frame:04d}.png', + eliminated=eliminated_now, + dark_background=dark_background, + ) + durations.append( + KEY_FRAME_MS + if step == frames_per_transfer - 1 + else transition_step_ms(frames_per_transfer) + ) + frame += 1 + eliminated.add(loser) + + render_frame( + voters, + candidates, + trace.final_choices, + trace.final_tallies, + favorability_pct, + wins, + colors, + labels, + 'Final two', + output_dir / f'{frame:04d}.png', + eliminated=set(range(n_cands)) - set(trace.active_candidates), + dark_background=dark_background, + ) + durations.append(KEY_FRAME_MS) + + frame_paths = sorted(output_dir.glob('[0-9][0-9][0-9][0-9].png')) + images = [Image.open(path) for path in frame_paths] + gif_path = output_dir / 'collapse_2d_irv.gif' + images[0].save( + gif_path, + save_all=True, + append_images=images[1:], + duration=durations, + loop=0, + ) + for image in images: + image.close() + return output_dir + + +if __name__ == '__main__': + n_voters = 5000 + n_cands = 9 + max_trials = 100_000 + frames_per_transfer = 60 + disp = 0.5 + palette_name = 'Bold_10' + dark_background = True + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + output_dir = RESULTS_DIR / ( + f'collapse_2d_irv_{timestamp}_nc{n_cands}_nv{n_voters}' + ) + + result = find_center_outward_election( + n_voters, + n_cands, + max_trials, + disp=disp, + ) + if result is None: + raise RuntimeError( + 'No strict center-outward collapse found. ' + 'Increase max_trials or reduce n_cands.' + ) + + trial, voters, candidates, rankings, trace = result + print(f'Found strict center-outward IRV collapse on trial {trial}.') + print( + 'Elimination order:', + ' -> '.join(candidate_name(round_.eliminated) for round_ in trace.rounds), + ) + print( + 'Final two:', + ', '.join(candidate_name(candidate) for candidate in trace.active_candidates), + ) + run_irv_animation( + voters, + candidates, + rankings, + trace, + output_dir, + palette_name=palette_name, + frames_per_transfer=frames_per_transfer, + dark_background=dark_background, + ) + print(f'Saved frames and GIF to {output_dir.resolve()}') diff --git a/examples/collapse_finder_2d_tvr.py b/examples/collapse_finder_2d_tvr.py new file mode 100644 index 0000000..8deb9ac --- /dev/null +++ b/examples/collapse_finder_2d_tvr.py @@ -0,0 +1,407 @@ +"""Find and animate a 2D Baldwin/TVR Condorcet-winner election. + +Baldwin satisfies the Condorcet criterion: a Condorcet winner has an +above-average Borda score and is never eliminated. Total Vote Runoff uses the +same elimination rule and shares this Condorcet consistency. +""" + +from datetime import datetime +from pathlib import Path + +import matplotlib + +matplotlib.use('Agg') +import matplotlib.pyplot as plt +import numpy as np +from PIL import Image + +from elsim.elections import normal_electorate, normed_dist_utilities +from elsim.methods import condorcet_from_matrix, ranked_election_to_matrix +from elsim.methods.baldwin import BaldwinResult, baldwin_rounds +from elsim.strategies import honest_rankings + +from examples.collapse_2d_shared import ( + KEY_FRAME_MS, + RESULTS_DIR, + ceildiv, + candidate_name, + create_frame_scaffold, + count_wins, + prepare_palette_and_labels, + sort_candidates_bell_curve, + transition_step_ms, +) + + +def validate_condorcet_winner(result, candidates, election): + """Keep traces where Baldwin elects the verified center Condorcet winner.""" + if result is None: + return None + candidates = np.asarray(candidates) + condorcet_winner = condorcet_from_matrix( + ranked_election_to_matrix(election) + ) + if condorcet_winner is None: + return None + center_candidate = int(np.argmin(np.linalg.norm(candidates, axis=1))) + if condorcet_winner != center_candidate or result.winner != condorcet_winner: + return None + return result + + +def simulate_tvr_rounds(election, candidates): + """Run Baldwin and verify it elects the center Condorcet winner.""" + return validate_condorcet_winner( + baldwin_rounds(election, tiebreaker=None), + candidates, + election, + ) + + +def find_center_convergent_election(n_voters, n_cands, max_trials, disp=1.0): + """Sample elections whose center candidate is the Condorcet winner.""" + for trial in range(1, max_trials + 1): + voters, candidates = normal_electorate( + n_voters, + n_cands, + dims=2, + disp=disp, + ) + candidates[0] = 0.0 + candidates = sort_candidates_bell_curve(candidates) + utilities = normed_dist_utilities(voters, candidates) + rankings = np.asarray(honest_rankings(utilities)) + trace = simulate_tvr_rounds(rankings, candidates) + if trace is not None: + return trial, voters, candidates, rankings, trace + return None + + +def average_ranks_from_borda(borda_scores, n_active, n_voters): + """Convert one-based Borda scores to one-based average ranks.""" + return (n_active + 1) - np.asarray(borda_scores) / n_voters + + +def _plot_borda_panel( + axis, + borda_scores, + n_active, + n_cands, + n_voters, + labels, + colors, + fg, + grid, + dead_zone_color, + title, +): + """Plot average ranks with a dead zone for eliminated rank slots.""" + dead_height = n_cands - n_active + average_scores = np.asarray(borda_scores, dtype=float) / n_voters + average_ranks = average_ranks_from_borda( + borda_scores, + n_active, + n_voters, + ) + bar_segments = np.maximum(average_scores, 0) + for candidate in range(n_cands): + if borda_scores[candidate] <= 0: + bar_segments[candidate] = 0 + + bars = axis.bar( + range(n_cands), + bar_segments, + bottom=dead_height, + tick_label=list(labels), + color=colors, + ) + for candidate, rect in enumerate(bars): + if bar_segments[candidate] > 0 and borda_scores[candidate] > 0: + axis.annotate( + f'{average_ranks[candidate]:.1f}', + xy=(rect.get_x() + rect.get_width() / 2, rect.get_y() + rect.get_height()), + xytext=(0, 3), + textcoords='offset points', + ha='center', + va='bottom', + color=fg, + ) + + axis.set_ylim(0, n_cands) + if dead_height: + axis.axhspan(0, dead_height, color=dead_zone_color, zorder=0) + tick_values = list(range(n_cands + 1)) + tick_labels = [ + '' if value <= dead_height else str(n_cands + 1 - value) + for value in tick_values + ] + axis.set_yticks(tick_values) + axis.set_yticklabels(tick_labels) + axis.set_ylabel('Avg. rank (1=best)') + axis.grid(True, alpha=0.25, axis='y', color=grid) + axis.set_axisbelow(True) + axis.text( + 0.5, + 1.04, + title, + transform=axis.transAxes, + ha='center', + va='center', + color=fg, + ) + + +def render_frame( + voters, + candidates, + ballots, + borda_scores, + n_active, + favorability_pct, + wins, + colors, + labels, + frame_title, + output_path, + eliminated=None, + dark_background=True, +): + """Render one Baldwin frame using the shared panel scaffold.""" + fig, axes, active_colors, theme = create_frame_scaffold( + voters, + candidates, + ballots, + favorability_pct, + wins, + colors, + labels, + eliminated=eliminated, + dark_background=dark_background, + ) + _, fg, grid, dead_zone_color = theme + _plot_borda_panel( + axes['middle'], + borda_scores, + n_active, + len(candidates), + len(voters), + labels, + active_colors, + fg, + grid, + dead_zone_color, + frame_title, + ) + fig.tight_layout() + fig.savefig(output_path, facecolor=theme[0], edgecolor='none') + plt.close(fig) + + +def _clear_png_frames(output_dir): + """Remove only numbered PNG frames from a reusable animation directory.""" + for path in Path(output_dir).glob('[0-9][0-9][0-9][0-9].png'): + path.unlink() + + +def run_tvr_animation( + voters, + candidates, + rankings, + trace: BaldwinResult, + output_dir, + *, + palette_name='Bold_10', + frames_per_transfer=60, + dark_background=True, + seed=0, +): + """Render a full Baldwin elimination trace and save its GIF.""" + voters = np.asarray(voters) + candidates = np.asarray(candidates) + rankings = np.asarray(rankings) + n_voters = len(voters) + n_cands = len(candidates) + if rankings.shape != (n_voters, n_cands): + raise ValueError( + f'Rankings shape {rankings.shape} does not match ' + f'{n_voters} voters and {n_cands} candidates.' + ) + + output_dir = Path(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + _clear_png_frames(output_dir) + colors, labels = prepare_palette_and_labels( + palette_name, + n_cands, + dark_background, + ) + utilities = normed_dist_utilities(voters, candidates) + favorability_pct = utilities.mean(axis=0) * 100 + wins = count_wins(ranked_election_to_matrix(rankings)) + choices = rankings[:, 0].copy() + eliminated = set() + durations = [] + frame = 0 + first_round = trace.rounds[0] + render_frame( + voters, + candidates, + choices, + first_round.borda_before, + n_cands, + favorability_pct, + wins, + colors, + labels, + 'Baldwin start', + output_dir / f'{frame:04d}.png', + eliminated=eliminated, + dark_background=dark_background, + ) + durations.append(KEY_FRAME_MS) + frame += 1 + + rng = np.random.default_rng(seed) + for round_index, round_ in enumerate(trace.rounds, start=1): + loser = int(round_.eliminated) + eliminated_now = eliminated | {loser} + n_active = n_cands - len(eliminated) + render_frame( + voters, + candidates, + choices, + round_.borda_before, + n_active, + favorability_pct, + wins, + colors, + labels, + f'Round {round_index}: eliminate {candidate_name(loser)}', + output_dir / f'{frame:04d}.png', + eliminated=eliminated_now, + dark_background=dark_background, + ) + durations.append(KEY_FRAME_MS) + frame += 1 + + higher_ranked = round_.higher_ranked_candidates + transferred_voters = round_.transferred_voters + transferred_to = round_.transferred_to + order = rng.permutation(n_voters) + per_frame = max(1, ceildiv(n_voters, frames_per_transfer)) + running_borda = round_.borda_before.copy() + running_choices = choices.copy() + for step in range(frames_per_transfer): + start = step * per_frame + stop = min(start + per_frame, n_voters) + for voter in order[start:stop]: + higher = higher_ranked[voter] + running_borda[higher] -= 1 + running_borda[loser] -= n_active - len(higher) + transferred = np.flatnonzero(transferred_voters == voter) + if len(transferred): + running_choices[voter] = transferred_to[transferred[0]] + if step == frames_per_transfer - 1: + if not np.array_equal(running_borda, round_.borda_after): + raise AssertionError('Baldwin Borda transition did not match trace.') + running_borda = round_.borda_after.copy() + running_choices[transferred_voters] = transferred_to + render_frame( + voters, + candidates, + running_choices, + running_borda, + n_active, + favorability_pct, + wins, + colors, + labels, + f'Round {round_index}: eliminate {candidate_name(loser)}', + output_dir / f'{frame:04d}.png', + eliminated=eliminated_now, + dark_background=dark_background, + ) + durations.append( + KEY_FRAME_MS + if step == frames_per_transfer - 1 + else transition_step_ms(frames_per_transfer) + ) + frame += 1 + choices = running_choices + eliminated.add(loser) + + render_frame( + voters, + candidates, + trace.final_choices, + trace.rounds[-1].borda_after, + 1, + favorability_pct, + wins, + colors, + labels, + f'Baldwin winner: {candidate_name(trace.winner)}', + output_dir / f'{frame:04d}.png', + eliminated=set(range(n_cands)) - {int(trace.winner)}, + dark_background=dark_background, + ) + durations.append(KEY_FRAME_MS) + + frame_paths = sorted(output_dir.glob('[0-9][0-9][0-9][0-9].png')) + images = [Image.open(path) for path in frame_paths] + gif_path = output_dir / 'collapse_2d_tvr.gif' + images[0].save( + gif_path, + save_all=True, + append_images=images[1:], + duration=durations, + loop=0, + ) + for image in images: + image.close() + return output_dir + + +if __name__ == '__main__': + n_voters = 5000 + n_cands = 9 + max_trials = 100_000 + frames_per_transfer = 60 + disp = 0.5 + palette_name = 'Bold_10' + dark_background = True + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + output_dir = RESULTS_DIR / ( + f'collapse_2d_tvr_{timestamp}_nc{n_cands}_nv{n_voters}' + ) + + result = find_center_convergent_election( + n_voters, + n_cands, + max_trials, + disp=disp, + ) + if result is None: + raise RuntimeError( + 'No Baldwin election with a center Condorcet winner found. ' + 'Increase max_trials or reduce n_cands.' + ) + + trial, voters, candidates, rankings, trace = result + print(f'Found Baldwin election with a center Condorcet winner on trial {trial}.') + print( + 'Elimination order:', + ' -> '.join(candidate_name(round_.eliminated) for round_ in trace.rounds), + ) + print('Baldwin winner:', candidate_name(trace.winner)) + run_tvr_animation( + voters, + candidates, + rankings, + trace, + output_dir, + palette_name=palette_name, + frames_per_transfer=frames_per_transfer, + dark_background=dark_background, + ) + print(f'Saved frames and GIF to {output_dir.resolve()}') diff --git a/tests/test_collapse_2d_shared.py b/tests/test_collapse_2d_shared.py new file mode 100644 index 0000000..ac7617e --- /dev/null +++ b/tests/test_collapse_2d_shared.py @@ -0,0 +1,108 @@ +"""Tests for the pure geometry helpers used by collapse examples.""" + +import matplotlib + +matplotlib.use('Agg') +import matplotlib.pyplot as plt +import numpy as np + +from examples.collapse_2d_shared import ( + count_wins, + create_frame_scaffold, + get_theme, + prepare_palette_and_labels, + setup_scatter_axis_sigma, + sort_candidates_bell_curve, + voronoi_plot_2d_axes, +) + + +def test_sort_candidates_bell_curve_orders_hemispheres_around_origin(): + """The helper should order candidates outward-in, center, and in-out.""" + candidates = np.array([ + [-2.0, 0.0], + [1.0, 0.0], + [0.0, 0.0], + [-1.0, 0.0], + [2.0, 0.0], + ]) + + ordered = sort_candidates_bell_curve(candidates) + + np.testing.assert_array_equal( + ordered, + [ + [-2.0, 0.0], + [-1.0, 0.0], + [0.0, 0.0], + [1.0, 0.0], + [2.0, 0.0], + ], + ) + + +def test_two_point_voronoi_handles_same_x_coordinates(): + """A vertical perpendicular bisector must not divide by zero.""" + fig, axis = plt.subplots() + axis.set_xlim(-2, 2) + axis.set_ylim(-2, 2) + + voronoi_plot_2d_axes( + axis, + np.array([[0.0, -1.0], [0.0, 1.0]]), + ) + + assert len(axis.lines) == 1 + plt.close(fig) + + +def test_count_wins_counts_only_strict_pairwise_wins(): + """Pairwise ties should not be counted as wins for either candidate.""" + matrix = np.array([ + [0, 3, 2], + [2, 0, 3], + [3, 2, 0], + ]) + + assert count_wins(matrix) == [1, 1, 1] + + +def test_palette_and_theme_helpers_return_rendering_configuration(): + """Palette labels and theme colors should be ready for shared rendering.""" + colors, labels = prepare_palette_and_labels('Bold_10', 3, True) + + assert len(colors) == 3 + assert labels == 'ABC' + assert get_theme(True)[-1] == '0.15' + assert get_theme(False)[-1] == '0.88' + + +def test_scatter_axis_uses_only_visible_sigma_ticks(): + """Scatter axes should place ticks inside the ±1.5σ plot limits.""" + fig, axis = plt.subplots() + voters = np.array([[-1.0, 0.0], [1.0, 0.0]]) + setup_scatter_axis_sigma(axis, voters) + + sigma = np.std(voters) + np.testing.assert_array_equal(axis.get_xticks(), [-sigma, 0.0, sigma]) + plt.close(fig) + + +def test_frame_scaffold_builds_common_panels_and_theme(): + """The scaffold should construct all shared panels for method renderers.""" + voters = np.array([[-1.0, 0.0], [0.0, 0.0], [1.0, 0.0]]) + candidates = np.array([[-1.0, 0.0], [0.0, 0.5], [1.0, 0.0]]) + colors, labels = prepare_palette_and_labels('Bold_10', 3, True) + fig, axes, _, theme = create_frame_scaffold( + voters, + candidates, + np.array([0, 1, 2]), + np.array([50.0, 60.0, 50.0]), + [1, 1, 1], + colors, + labels, + ) + + assert set(axes) == {'scatter', 'middle', 'favorability', 'wins'} + assert theme[-1] == '0.15' + plt.close(fig) diff --git a/tests/test_collapse_finder_2d_both.py b/tests/test_collapse_finder_2d_both.py new file mode 100644 index 0000000..0c72ce6 --- /dev/null +++ b/tests/test_collapse_finder_2d_both.py @@ -0,0 +1,31 @@ +"""Tests for the combined IRV and Baldwin example driver.""" + +import numpy as np + +from examples.collapse_finder_2d_both import election_to_traces +from elsim.methods import condorcet + + +def test_election_to_traces_returns_both_method_traces(): + """The driver should derive compatible IRV and Baldwin traces once.""" + candidates = np.array([ + [-1.0, 0.0], + [0.0, 0.0], + [0.5, 1.0], + ]) + voters = np.array([ + [-1.0, 0.0], + [-1.0, 0.0], + [0.5, 1.0], + [0.5, 1.0], + [0.0, 0.0], + ]) + + rankings, irv_trace, tvr_trace = election_to_traces(voters, candidates) + + assert rankings.shape == (5, 3) + assert irv_trace is not None + assert tvr_trace is not None + assert condorcet(rankings) == 1 + assert irv_trace.rounds[0].eliminated == 1 + assert tvr_trace.winner == 1 diff --git a/tests/test_collapse_finder_2d_irv.py b/tests/test_collapse_finder_2d_irv.py new file mode 100644 index 0000000..61134a2 --- /dev/null +++ b/tests/test_collapse_finder_2d_irv.py @@ -0,0 +1,97 @@ +"""Tests for the 2D IRV collapse validator and animation smoke path.""" + +import matplotlib + +matplotlib.use('Agg') +import numpy as np + +from examples.collapse_finder_2d_irv import ( + run_irv_animation, + simulate_irv_rounds, +) +from elsim.methods import condorcet +from elsim.methods.irv import irv_rounds + + +def _center_outward_election(): + """Return a small election whose center candidate is eliminated first.""" + candidates = np.array([ + [-1.0, 0.0], + [0.0, 0.0], + [0.5, 1.0], + ]) + election = np.array([ + [0, 1, 2], + [0, 1, 2], + [2, 1, 0], + [2, 1, 0], + [1, 0, 2], + ]) + return election, candidates + + +def test_center_outward_validator_accepts_expected_elimination_order(): + """A clean center-first trace should pass geometric validation.""" + election, candidates = _center_outward_election() + + result = simulate_irv_rounds(election, candidates) + + assert result is not None + assert [round_.eliminated for round_ in result.rounds] == [1] + np.testing.assert_array_equal(result.active_candidates, [0, 2]) + assert condorcet(election) == result.rounds[0].eliminated + + +def test_center_outward_validator_rejects_noncenter_elimination(): + """An outer candidate eliminated before the center must be rejected.""" + election, candidates = _center_outward_election() + noncenter_first = election.copy() + noncenter_first[0] = [1, 0, 2] + + result = simulate_irv_rounds(noncenter_first, candidates) + + assert result is None + + +def test_center_outward_validator_rejects_initial_zero_vote_candidates(): + """The validator must reject traces with eager zero-vote exclusions.""" + _, candidates = _center_outward_election() + election = np.array([ + [0, 1, 2], + [0, 2, 1], + [1, 0, 2], + [1, 2, 0], + [1, 0, 2], + ]) + + result = irv_rounds(election, stop_at=2) + + assert result is not None + assert len(result.initially_eliminated) > 0 + assert simulate_irv_rounds(election, candidates) is None + + +def test_irv_animation_writes_a_gif_for_a_tiny_election(tmp_path): + """The renderer should produce a GIF from a small traced election.""" + election, candidates = _center_outward_election() + trace = simulate_irv_rounds(election, candidates) + voters = np.array([ + [-1.0, 0.0], + [-0.5, 0.0], + [1.0, 0.0], + [0.5, 0.0], + [0.0, 0.0], + ]) + + assert trace is not None + output_dir = run_irv_animation( + voters, + candidates, + election, + trace, + tmp_path, + frames_per_transfer=2, + seed=7, + ) + + assert (output_dir / 'collapse_2d_irv.gif').is_file() diff --git a/tests/test_collapse_finder_2d_tvr.py b/tests/test_collapse_finder_2d_tvr.py new file mode 100644 index 0000000..3f909b7 --- /dev/null +++ b/tests/test_collapse_finder_2d_tvr.py @@ -0,0 +1,123 @@ +"""Tests for the 2D Baldwin collapse validator and animation.""" + +import matplotlib + +matplotlib.use('Agg') +import numpy as np + +from examples.collapse_finder_2d_tvr import ( + average_ranks_from_borda, + find_center_convergent_election, + run_tvr_animation, + simulate_tvr_rounds, +) +from elsim.methods import condorcet +from elsim.methods.baldwin import baldwin_rounds + + +def _center_winner_election(): + """Return a small election whose nearest candidate wins Baldwin.""" + candidates = np.array([ + [-1.0, 0.0], + [0.5, 1.0], + [0.0, 0.0], + ]) + election = np.array([ + [0, 2, 1], + [0, 2, 1], + [1, 2, 0], + [1, 2, 0], + [2, 0, 1], + ]) + return election, candidates + + +def test_borda_replay_matches_every_recorded_after_score(): + """The prescribed voter-wise score changes must reproduce Baldwin traces.""" + election, _ = _center_winner_election() + result = baldwin_rounds(election) + + assert result is not None + eliminated = set() + n_cands = election.shape[1] + for round_ in result.rounds: + n_active = n_cands - len(eliminated) + running = round_.borda_before.copy() + for higher in round_.higher_ranked_candidates: + running[higher] -= 1 + running[round_.eliminated] -= n_active - len(higher) + np.testing.assert_array_equal(running, round_.borda_after) + eliminated.add(round_.eliminated) + + +def test_average_rank_formula_is_one_based_and_bounded(): + """Borda scores should map best and worst active ranks to 1 and n_active.""" + scores = np.array([12.0, 8.0, 4.0]) + ranks = average_ranks_from_borda(scores, n_active=3, n_voters=4) + + np.testing.assert_allclose(ranks, [1.0, 2.0, 3.0]) + assert np.all((1 <= ranks) & (ranks <= 3)) + + +def test_center_validator_accepts_verified_condorcet_winner_and_rejects_other(): + """The validator should require the center to be the pairwise winner.""" + election, candidates = _center_winner_election() + + trace = simulate_tvr_rounds(election, candidates) + assert trace is not None + assert condorcet(election) == trace.winner == 2 + candidates_with_different_center = candidates[[2, 1, 0]] + assert simulate_tvr_rounds(election, candidates_with_different_center) is None + + +def test_finder_keeps_a_pairwise_verified_center_winner(monkeypatch): + """The finder should retain only a sampled election with a center Condorcet winner.""" + voters = np.array([ + [-1.0, 0.0], + [-1.0, 0.0], + [0.5, 1.0], + [0.5, 1.0], + [0.0, 0.0], + ]) + candidates = np.array([ + [99.0, 99.0], + [-1.0, 0.0], + [0.5, 1.0], + ]) + + monkeypatch.setattr( + 'examples.collapse_finder_2d_tvr.normal_electorate', + lambda *args, **kwargs: (voters.copy(), candidates.copy()), + ) + result = find_center_convergent_election(5, 3, 1) + + assert result is not None + _, _, kept_candidates, rankings, trace = result + center = int(np.argmin(np.linalg.norm(kept_candidates, axis=1))) + assert condorcet(rankings) == center == trace.winner + + +def test_tvr_animation_writes_a_gif_for_a_tiny_election(tmp_path): + """The Baldwin renderer should produce a GIF for a small complete trace.""" + election, candidates = _center_winner_election() + voters = np.array([ + [-1.0, 0.0], + [-0.5, 0.0], + [1.0, 0.0], + [0.5, 0.0], + [0.0, 0.0], + ]) + trace = simulate_tvr_rounds(election, candidates) + + assert trace is not None + output_dir = run_tvr_animation( + voters, + candidates, + election, + trace, + tmp_path, + frames_per_transfer=2, + seed=7, + ) + + assert (output_dir / 'collapse_2d_tvr.gif').is_file()