#!/usr/bin/env python3 """ Simulator for the bespoke legislative election rule. Prints a short summary and several tables. "By" James Heaney, but I heavily modified a chassis handed to me by ChatGPT 5, which took many hours, but was still worlds easier than writing it myself. """ import os import random import numpy as np import pandas as pd from itertools import combinations from collections import Counter from datetime import datetime import math import matplotlib.pyplot as plt import time # ------------------------- # Parameters (tweak here) # ------------------------- #SEED = 2025 # change for different deterministic trial RUNS = 500 ts = datetime.now().strftime("%Y%m%d%H%M%S") os.makedirs(f"sims/{ts}", exist_ok=True) N = int(random.uniform(20,100)) # total members MAJORITY_FRACTION = random.uniform(0.5, 0.91) MAJORITY_SIZE = int(np.ceil(N*MAJORITY_FRACTION)) MINORITY_SIZE = N - MAJORITY_SIZE NOM_THRESHOLD = int(np.ceil(N / 10)) # 1/10 rule - change to N/N to allow EVERYONE to nominate, but probably increase Veto Spice to 1 at that point. # Veto rules VETO_PER_BALLOT = 1 VETO_SPICE = 0.7 # Theoretical maximum fraction of candidates who could be vetoed. 0.7 by default = "seven-tenths" in proposal. MAX_SPICE_REDUCER = 0 if VETO_SPICE < 1 else 1 #print(VETO_SPICE, MAX_SPICE_REDUCER) # ensure local convenience sets exist majority = set(range(1, MAJORITY_SIZE + 1)) minority = set(range(MAJORITY_SIZE + 1, N + 1)) # ------------------------- # Helper functions # ------------------------- def pairwise_matrix(cands, ballots, valid_ballots): """Return pairwise matrix as dict of dicts: mat[a][b] = # ballots preferring a to b""" mat = {a: {b: 0 for b in cands} for a in cands} for a, b in combinations(cands, 2): a_over_b = sum(1 for r in ballots if r.index(a) < r.index(b)) b_over_a = valid_ballots - a_over_b mat[a][b] = a_over_b mat[b][a] = b_over_a return mat def condorcet_winner(cands, ballots, valid_ballots): """Return Condorcet winner (strict majority head-to-head) or None.""" if not cands: return None mat = pairwise_matrix(cands, ballots, valid_ballots) for c in cands: if all(mat[c][d] > valid_ballots / 2 for d in cands if d != c): return c return None def smith_set(cands, mat, valid_ballots): """Compute Smith set by brute force using pairwise matrix mat (dict-of-dict).""" if not cands: return [] from itertools import combinations for k in range(1, len(cands) + 1): for subset in combinations(cands, k): S = set(subset) ok = True for s in S: for t in cands: if t in S: continue # s must beat t by simple majority if mat[s][t] <= valid_ballots / 2: ok = False break if not ok: break if ok: return sorted(S) return sorted(cands) # ------------------------- # Core single-trial function # ------------------------- #def run_single_trial(seed=None, verbose=True, export_grouped_csv=True, grouped_csv_path="grouped_ballots_trial.csv"): def run_single_trial(verbose=True, export_grouped_csv=True, grouped_csv_path="grouped_ballots_trial"): """ Strategic single-trial runner implementing the behaviors you specified: - Majority selects up to 4 nominees; may choose fewer if fragmentation risk is high. - Majority assigns ASSIGNED_SUPPORTERS members to guarantee support for those nominees. - Minority always includes #35 plus two electable/moderate picks (from ELECTABLE_CORE or neighbors). - Minority assigns vetoers to the most ideological majority nominees (and #13 fallback). - Committed vs moderate behavior and small rebellion probabilities applied. - Builds full ranked ballots, applies veto sweep, then Condorcet / Smith elimination. Returns a dict similar to your previous function. """ # Party discipline / behavior MAJ_COMMITTED = random.uniform(0.75, 0.98) #0.95 MI_COMMITTED = random.uniform(0.75, 0.98) REBELLION_PROB = random.uniform(0.0, 0.2) COMMIT_NOMINATE_FOLLOW = 1 - REBELLION_PROB REBELLION_MODERATE = random.uniform(REBELLION_PROB, 0.6) REBELLION_VOTE = random.uniform(0.05, 0.15) # ---- parameters specific to this strategic model (tweak if desired) ---- MAX_MAJ_NOMINEES = int(np.floor(MAJORITY_SIZE/NOM_THRESHOLD)) MAX_MIN_NOMINEES = int(np.floor(MINORITY_SIZE/NOM_THRESHOLD)) ASSIGNED_SUPPORTERS = MAJORITY_SIZE #int(random.uniform(NOM_THRESHOLD, NOM_THRESHOLD+1)) # majority assigns 5-6 members to support nominees # no, ALL nominees ASSIGNED_SUPPORTERS_MI = MINORITY_SIZE #int(random.uniform(NOM_THRESHOLD, NOM_THRESHOLD+1)) # majority assigns 5-6 members to support nominees # no, ALL nominees MAJORITY_FRAGMENTATION_TOLERANCE = random.random() # heuristic: if estimated fragmentation risk > tolerance, reduce nominees MAJORITY_TARGET_CENTER = int(random.randrange(np.floor(MAJORITY_SIZE*0.3),np.floor(MAJORITY_SIZE*0.7))) # majority leadership's ideal target MAJORITY_CANDIDATE_POOL = [MAJORITY_TARGET_CENTER] MAJORITY_CANDIDATE_POOL.extend([random.randrange(1,MAJORITY_SIZE+3) for x in range(MAX_MAJ_NOMINEES - 1)]) #MAJORITY_CANDIDATE_POOL.extend([i+1 for i in range(MAX_MAJ_NOMINEES - 1)]) if random.random() < 0.67: HIT_NOMINEE_FIRST = True; else: HIT_NOMINEE_FIRST = False; #print (MAJORITY_CANDIDATE_POOL) if random.random() < 0.35: random.shuffle(MAJORITY_CANDIDATE_POOL) MINORITY_CANDIDATE_POOL = [random.randrange(int(np.ceil(N/2+1))-2, MAJORITY_SIZE+3)] MINORITY_CANDIDATE_POOL.extend([random.randrange(int(np.ceil(N/2+1))-2,N+1) for x in range(MAX_MIN_NOMINEES - 1)]) # picks for the two other minority nominees if random.random() < 0.35: random.shuffle(MINORITY_CANDIDATE_POOL) seed=None # Optional determinism if seed is not None: random.seed(seed) np.random.seed(seed) # ---- Step A: Majority chooses how many nominees to run (1..4) and which ones ---- # heuristic approach: majority tries max, but reduces number if fragmentation risk from moderates is estimated high. # fragmentation risk estimate: fraction_of_moderates * moderate_nomination_defection_rate # Use parameters from your environment: fraction_majority_moderates = 1.0 - MAJ_COMMITTED # e.g., 0.05 if MAJ_COMMITTED=0.95 moderate_defect_nom_rate = 0.20 # 20% as you specified for moderates on nominations estimated_fragmentation_risk = fraction_majority_moderates * (moderate_defect_nom_rate * random.uniform(0.8, 1.2)) # start with maximal slate and possibly reduce maj_nom_count = MAX_MAJ_NOMINEES if random.random() > random.random(): #estimated_fragmentation_risk > MAJORITY_FRAGMENTATION_TOLERANCE: # reduce nominees to attempt to concentrate first-place support maj_nom_count = max(1, MAX_MAJ_NOMINEES - 1) # pick majority nominees from the pool (always include the center 13) maj_candidates = [c for c in MAJORITY_CANDIDATE_POOL if c <= MAJORITY_SIZE] # keep only majority IDs # ensure center present if MAJORITY_TARGET_CENTER not in maj_candidates: maj_candidates.insert(0, MAJORITY_TARGET_CENTER) maj_nominees = sorted(maj_candidates[:maj_nom_count]) # ---- Step B: Minority nominates (deterministic policy) ---- # Always include #35; then pick two from MINORITY_CANDIDATE_POOL (distinct) min_nominees = [] for c in MINORITY_CANDIDATE_POOL: if c not in min_nominees: min_nominees.append(c) if len(min_nominees) >= 3: break min_nominees = sorted(min_nominees) # combine nominees and dedupe nominees = sorted(set(maj_nominees + min_nominees)) # ---- Step C: Nomination-stage assignments (majority supporters + minority vetoers) ---- # Majority assigns ASSIGNED_SUPPORTERS distinct members to guarantee nominations; round-robin map to majority nominees. maj_members_list = list(range(1, MAJORITY_SIZE+1)) random.shuffle(maj_members_list) maj_assigned_support = {} # member -> candidate they will nominate/support for i in range(min(ASSIGNED_SUPPORTERS, len(maj_members_list))): member_id = maj_members_list[i] if len(maj_nominees) > 0: assigned_candidate = maj_nominees[i % len(maj_nominees)] else: assigned_candidate = MAJORITY_TARGET_CENTER maj_assigned_support[member_id] = assigned_candidate #print ("#" + str(member_id) + " has been assigned to support " + str(assigned_candidate) + ". | maj_nominees:i :: " + str(maj_nominees) + ":" + str(i)) min_members_list = list(range(MAJORITY_SIZE + 1, N + 1)) random.shuffle(min_members_list) min_assigned_support = {} # member -> candidate they will nominate/support for i in range(min(ASSIGNED_SUPPORTERS_MI, len(min_members_list))): member_id = min_members_list[i] #if len(min_nominees) > 0: assigned_candidate = min_nominees[i % len(min_nominees)] #else: # assigned_candidate = MAJORITY_TARGET_CENTER min_assigned_support[member_id] = assigned_candidate #print ("#" + str(member_id) + " has been assigned to support " + str(assigned_candidate) + ". | min_nominees:i :: " + str(min_nominees) + ":" + str(i)) # Minority assigns vetoers to top ideological majority nominees (most distant from center) # Identify majority nominees and rank by ideological extremity relative to center (center ~22) maj_on_ballot = [c for c in nominees if c in majority] veto_targets = [] if maj_on_ballot: # take up to two extremes sorted_extremes = sorted(maj_on_ballot, key=lambda x: abs(x - np.floor(MAJORITY_SIZE * 0.9)), reverse=True) veto_targets = sorted_extremes[:2] if (HIT_NOMINEE_FIRST == True): veto_targets.insert(0, MAJORITY_TARGET_CENTER) else: veto_targets.append(MAJORITY_TARGET_CENTER) #print ("veto_targets:", veto_targets) # fallback: if no extreme, have #13 as secondary target if present #if len(veto_targets) < 2 and MAJORITY_TARGET_CENTER in nominees and MAJORITY_TARGET_CENTER not in veto_targets: # veto_targets.append(MAJORITY_TARGET_CENTER) # ---- Step D: Nomination outcome (simulate nomination ballots) ---- # We'll simulate nominations (each member writes one single-name nomination). Leadership-assigned supporters will cast for assigned. nomination_ballots = [] for m in range(1, N+1): if m in maj_assigned_support: nomination_ballots.append(maj_assigned_support[m]) continue if m in min_assigned_support: nomination_ballots.append(min_assigned_support[m]) continue # else behavior: committed members follow leadership picks with high prob; moderates randomize slightly is_maj = (m <= MAJORITY_SIZE) committed = (random.random() < (MAJ_COMMITTED if is_maj else MI_COMMITTED)) nomvote = None if committed: if is_maj: # pick from majority nominees, favor center 13 nomvote = random.choice(maj_nominees) nomination_ballots.append(nomvote) #nomination_ballots.append(random.choices(maj_nominees, weights=[0.5 if c==MAJORITY_TARGET_CENTER else 0.5/ (len(maj_nominees)-1 or 1) for c in maj_nominees])[0] if maj_nominees else MAJORITY_TARGET_CENTER) else: # committed minority allocates mostly to the planned min_nominees nomvote = random.choice(min_nominees) nomination_ballots.append(nomvote) else: # moderate: may defect; with 20% on nomination stage for moderates choose a centrist-of-defectors if random.random() < REBELLION_MODERATE: # choose a centrist near the mean index of defectors (approx center 22) # pick closest nominee to center center = int(round(sum(range(1,N+1))/N)) # ~22 best = min(nominees, key=lambda c: abs(c-center)) nomination_ballots.append(best) nomvote = best else: # pick party-preferred or moderate pick if is_maj: nomvote = random.choice(maj_nominees if maj_nominees else [MAJORITY_TARGET_CENTER]) nomination_ballots.append(nomvote) else: nomvote = random.choice(min_nominees) nomination_ballots.append(nomvote) #print ("#" + str(m) + " voted to nominate " + str(nomvote)) # Tally nominations and apply user’s nomination threshold (>= N/10) nom_count = {} for n in nomination_ballots: nom_count[n] = nom_count.get(n, 0) + 1 # Only candidates meeting the threshold remain nominees; if some planned nominees fail, fallback to top-k nominated final_nominees = [c for c,cnt in nom_count.items() if cnt >= NOM_THRESHOLD] if len(final_nominees) == 0: # fallback to the top-5 nominated persons final_nominees = [c for c,_ in sorted(nom_count.items(), key=lambda x:-x[1])[:5]] final_nominees = sorted(set(final_nominees)) # OVERRIDE NOMINEES #final_nominees = [1,2,14,22,25,26] VETO_THRESHOLD = VETO_PER_BALLOT * N / (VETO_SPICE * max(1, len(final_nominees) - MAX_SPICE_REDUCER)) ASSIGN_PER_VETO_TARGET = int(np.ceil(VETO_THRESHOLD)) # how many minority members assigned as vetoers per target (approx) # assign minority members to veto these targets (simple equal-chunk distribution) minority_list = list(range(MAJORITY_SIZE+1, N+1)) random.shuffle(minority_list) minority_veto_assignments = {} # candidate->list of minority members if veto_targets: # naive assign ASSIGN_PER_VETO_TARGET (or floor(len(minority)/len(veto_targets)) whichever smaller) per = ASSIGN_PER_VETO_TARGET #min(ASSIGN_PER_VETO_TARGET, max(1, len(minority_list) // max(1, len(veto_targets)))) idx = 0 for targ in veto_targets: assigned = minority_list[idx: idx + per] minority_veto_assignments[targ] = assigned idx += per print ("#", assigned, " assigned to veto #", targ) # ---- Step E: Build full ranked ballots under the voting rule ---- # Track how many vetoes each candidate has received so far veto_counts_so_far = {c: 0 for c in final_nominees} ballots = [] ballot_records = [] for m in range(1, N+1): is_maj = (m <= MAJORITY_SIZE) opp_party = minority if is_maj else majority # compute distances and ordering distances = {c: abs(m - c) for c in final_nominees} if is_maj: # strict majority-first rule (never rank a minority candidate before any majority candidate) ordered = sorted(final_nominees, key=lambda c: (0 if c in majority else 1, distances[c], c)) else: # minority voters: group by distance, within equal distance place own-party first dist_groups = {} for c in final_nominees: d = distances[c]; dist_groups.setdefault(d, []).append(c) ordered = [] for d in sorted(dist_groups.keys()): group = dist_groups[d] own = [c for c in group if (c in majority) == is_maj] other = [c for c in group if (c in majority) != is_maj] ordered.extend(sorted(own) + sorted(other)) ranking = ordered.copy() # rebellion swaps (minor chance) when other-party candidate is strictly closer for i in range(len(ranking)): for j in range(i+1, len(ranking)): a = ranking[i]; b = ranking[j] if ((a in majority) == is_maj) and ((b in majority) != is_maj) and (distances[b] < distances[a]): if random.random() < REBELLION_VOTE: ranking[i], ranking[j] = ranking[j], ranking[i] # apply majority assigned supporters: if a member is a supporter, put their assigned nominee first if m in maj_assigned_support: pref = maj_assigned_support[m] if pref in ranking: ranking = [pref] + [c for c in ranking if c != pref] # Determine which candidates this voter will veto # Start with any minority veto assignments veto_targets_for_member = [] veto_targets_for_member.extend([targ for targ, assigned_members in minority_veto_assignments.items() if m in assigned_members]) # Include additional coordination: all aware voters avoid over-vetoing #if is_maj or (not is_maj and m not in sum(minority_veto_assignments.values(), [])): # unassigned minority members and majority members are "aware" probabilistically #if random.random() < 0.8: # 80% follow coordination veto_targets_for_member.extend(ranking[::-1]) #veto_targets_for_member.extend([c for c in final_nominees if veto_counts_so_far[c] < VETO_THRESHOLD and ((is_maj and c in majority) or (not is_maj and c in minority))]) #print (m, veto_targets_for_member, veto_counts_so_far, VETO_THRESHOLD) # Apply vetoes sequentially for targ in veto_targets_for_member: if targ in ranking and veto_counts_so_far[targ] < VETO_THRESHOLD: ranking = [c for c in ranking if c != targ] + [targ] veto_counts_so_far[targ] += 1 break else: # candidate already at threshold, pick next eligible (closest by distance) available = [c for c in ranking if veto_counts_so_far[c] < VETO_THRESHOLD] if available: next_targ = max(available, key=lambda c: (0 if (c in opp_party) else 1, distances[c])) ranking = [c for c in ranking if c != next_targ] + [next_targ] veto_counts_so_far[next_targ] += 1 break ballots.append(ranking) ballot_records.append({'member': m, 'party': ('M' if is_maj else 'm'), 'ranking': ranking}) # ---- Step F: Veto sweep (two lowest slots counted as vetoes? user had VETO_PER_BALLOT earlier set to 1) ---- veto_counts = {c: 0 for c in final_nominees} for r in ballots: last_k = r[-VETO_PER_BALLOT:] if len(r) >= VETO_PER_BALLOT else r[:] for c in last_k: veto_counts[c] += 1 eliminated_by_veto = [c for c, v in veto_counts.items() if v >= VETO_THRESHOLD] remaining = [c for c in final_nominees if c not in eliminated_by_veto] # ---- Step G: Condorcet check and Smith elimination as before ---- winner = condorcet_winner(remaining, ballots, N) if remaining else None elimination_order = [] if winner is None and remaining and ballots is not None: current = remaining.copy() while True: pw = pairwise_matrix(current, ballots, N) # check Condorcet in current cw = None for c in current: if all(pw[c][d] > N/2 for d in current if d != c): cw = c; break if cw: winner = cw; break # compute Smith set S = smith_set(current, pw, N) outside = [c for c in current if c not in S] if outside: # eliminate outside-S candidate with fewest first-place votes first_counts = {c: sum(1 for r in ballots if r[0] == c) for c in current} min_fp = min(first_counts[c] for c in outside) candidates_min = [c for c in outside if first_counts[c] == min_fp] elim = random.choice(candidates_min) elimination_order.append(elim); current.remove(elim); continue else: # eliminate from S by fewest firsts first_counts = {c: sum(1 for r in ballots if r[0] == c) for c in current} min_fp = min(first_counts.values()) candidates_min = [c for c, v in first_counts.items() if v == min_fp] elim = random.choice(candidates_min) elimination_order.append(elim); current.remove(elim); continue # ---- Final output assembly & CSV export (grouped ballots) ---- grouped = Counter(">".join(map(str, b)) for b in ballots) grouped_rows = [{"ranking": list(map(int, k.split(">"))), "count": v} for k, v in grouped.items()] grouped_df = pd.DataFrame(grouped_rows, columns=["ranking", "count"]) if not grouped_df.empty: grouped_df = grouped_df.sort_values("count", ascending=False).reset_index(drop=True) ballot_records_df = pd.DataFrame(ballot_records) if export_grouped_csv: ballot_records_df.to_csv(f"{grouped_csv_path}.csv", index=False) grouped_df.to_csv(f"{grouped_csv_path}_GROUPED.csv", index=False) if verbose: # Open file in append mode so logs accumulate with open(f"{grouped_csv_path}_SUMMARY.txt", "w", encoding="utf-8") as f: def log(*args, **kwargs): """Prints to console and writes the same output to file.""" print(*args, **kwargs) print(*args, **kwargs, file=f) log("\n=== Trial summary ===") log("trial: ", grouped_csv_path) log("Size of body:", N) log("Size of majority/minority:", MAJORITY_SIZE, "/", MINORITY_SIZE) log("25th pct / Maj center / TRUE CENTER / 75th pct / Min center: \n", (N/4.0+1), "/", (MAJORITY_SIZE/2.0+1), "/", (N/2.0+1), "/", ((N/4.0+1)*3), "/", (MINORITY_SIZE/2.0+1)+MAJORITY_SIZE, "/") log("Nomination threshold (Max Maj/Max Min)", NOM_THRESHOLD, "(", MAX_MAJ_NOMINEES, "/", MAX_MIN_NOMINEES, ")") log("Maj/min Committed", MAJ_COMMITTED, "/", MI_COMMITTED, " | Rebellion: ", REBELLION_PROB) log("Frag Tolerance & Frag Risk: ", MAJORITY_FRAGMENTATION_TOLERANCE, "|", estimated_fragmentation_risk) log("Maj/min NomPools", MAJORITY_CANDIDATE_POOL, "/", MINORITY_CANDIDATE_POOL) log("Nomination tallies (top):", sorted(nom_count.items(), key=lambda x: -x[1])[:10]) log("Nominees (planned majority / minority):", maj_nominees, min_nominees) log("Final nominees (passed threshold):", final_nominees) log("Nom count:", len(final_nominees)) log("Veto threshold:", round(VETO_THRESHOLD, 2)) log("HIT_NOMINEE_FIRST:", HIT_NOMINEE_FIRST) log("Veto counts:", veto_counts) log("Eliminated by veto:", eliminated_by_veto) log("Remaining after veto:", remaining) log("Condorcet winner among remaining (if any):", winner) log("Elimination order after veto (if any):", elimination_order) log("Final winner (% of chamber):", winner, "(", np.round((winner-1)/(N)*100, 2), "%)") log("\nTop grouped ballots (ranking -> count):") log(grouped_df.head(12).to_string(index=False)) return { "seed": seed, "maj_nominees": maj_nominees, "min_nominees": min_nominees, "final_nominees": final_nominees, "nom_count": nom_count, "VETO_THRESHOLD": VETO_THRESHOLD, "veto_counts": veto_counts, "eliminated_by_veto": eliminated_by_veto, "remaining": remaining, "winner": str(winner) + "(" + str(np.round((winner-1)/(N)*100, 2)) + "%)", "elimination_order": elimination_order, "grouped_ballots": grouped_df, "ballot_records": ballot_records_df, } def ordinaryRun(): #res = run_single_trial(grouped_csv_path="sims/grouped_ballots_trial") wins = Counter() trial_hist = [] nominees_hist = [] elim_by_veto_hist = [] final_nominees_hist = [] final_nominees_len_hist = [] winners_hist = [] print(f"Running {RUNS} trials; outputs saved to sims/grouped_ballots_trial_.csv\n") for i in range(RUNS): #seed = seed_start + i # unique CSV path for each run csv_path = f"sims/{ts}/grouped_ballots_trial_{i}" # call the function (it will print its detailed per-run output) res = run_single_trial(grouped_csv_path=csv_path, verbose=True) # collect summary info winner = res.get("winner") wins[winner] += 1 trial_hist.append(i) winners_hist.append(winner) nominees_hist.append(len(res.get("final_nominees", []))) elim_by_veto_hist.append(len(res.get("eliminated_by_veto", []))) final_nominees_hist.append(res.get("final_nominees", [])) final_nominees_len_hist.append(len(res.get("remaining", []))) # Summary printing print("\n" + "="*60) print("SUMMARY AFTER ALL RUNS") print("="*60) print(f"Total runs: {RUNS}") print("\nWins by candidate (most to least):") for cand, cnt in wins.most_common(): pct = cnt / RUNS * 100 lab = cand if cand is not None else "NoWinner" print(f" {lab:>6} : {cnt:4d} runs ({pct:5.1f}%)") print("\nNominees per run: mean={:.2f}, std={:.2f}, min={}, max={}".format( np.mean(nominees_hist), np.std(nominees_hist), min(nominees_hist), max(nominees_hist) )) print("Veto-eliminations per run: mean={:.2f}, std={:.2f}, min={}, max={}".format( np.mean(elim_by_veto_hist), np.std(elim_by_veto_hist), min(elim_by_veto_hist), max(elim_by_veto_hist) )) print("Survivors per run: mean={:.2f}, std={:.2f}, min={}, max={}".format( np.mean(final_nominees_len_hist), np.std(final_nominees_len_hist), min(final_nominees_len_hist), max(final_nominees_len_hist) )) # write summary CSVs for later analysis summary_df = pd.DataFrame({ "candidate": list(wins.keys()), "wins": [wins[k] for k in wins] }).sort_values("wins", ascending=False).reset_index(drop=True) summary_df["pct"] = summary_df["wins"] / RUNS * 100 summary_df.to_csv(f"sims/{ts}/summary_wins.csv", index=False) stats_df = pd.DataFrame({ "trial": trial_hist, "winner this run": winners_hist, "nominees_per_run": nominees_hist, "elim_by_veto_per_run": elim_by_veto_hist, "final_noms": final_nominees_hist, #"seed": [seed_start + i for i in range(RUNS)] }) stats_df.to_csv(f"sims/{ts}/run_stats.csv", index=False) print("\nSaved summary_wins.csv and run_stats.csv to ./sims/") print("Done. (Use different seed_start if you want a different set of random seeds.)") # ------------------------- # Adapter + Heatmap sweep # ------------------------- def run_trial_middle_quarter(N_trial, majority_fraction_trial, seed=None, verbose=False): """ Wrapper around run_single_trial that overrides N and majority fraction. Returns winner index (integer) if present, else None. """ global N, MAJORITY_FRACTION, MAJORITY_SIZE, MINORITY_SIZE, majority, minority # save original values N_orig = N MAJORITY_FRACTION_orig = MAJORITY_FRACTION # override N = N_trial MAJORITY_FRACTION = majority_fraction_trial MAJORITY_SIZE = int(np.ceil(N * MAJORITY_FRACTION)) MINORITY_SIZE = N - MAJORITY_SIZE majority = set(range(1, MAJORITY_SIZE + 1)) minority = set(range(MAJORITY_SIZE + 1, N + 1)) if seed is not None: random.seed(seed) np.random.seed(seed) try: res = run_single_trial(verbose=verbose, export_grouped_csv=False) winner_raw = res.get("winner") if isinstance(winner_raw, str): # parse string like "13(29.55%)" to extract integer winner_idx = int(winner_raw.split("(")[0]) else: winner_idx = winner_raw except Exception as e: print("Trial failed:", e) winner_idx = None # restore original globals N = N_orig MAJORITY_FRACTION = MAJORITY_FRACTION_orig MAJORITY_SIZE = int(np.ceil(N * MAJORITY_FRACTION)) MINORITY_SIZE = N - MAJORITY_SIZE majority = set(range(1, MAJORITY_SIZE + 1)) minority = set(range(MAJORITY_SIZE + 1, N + 1)) return winner_idx def heatmap_middle_quarter(N_values, majority_fraction_values, runs_per_cell=20): """ Sweeps N and majority_fraction to compute % of winners in middle quarter of the chamber. """ results = [] total_cells = len(N_values) * len(majority_fraction_values) cell = 0 ts_local = datetime.now().strftime("%Y%m%d%H%M%S") start_time = time.time() for N_trial in N_values: row_vals = [] for f_trial in majority_fraction_values: cell += 1 print(f"[{cell}/{total_cells}] N={N_trial}, majority_fraction={f_trial:.2f}") winners = [] for r in range(runs_per_cell): seed = random.randrange(1, 2**31-1) w = run_trial_middle_quarter(N_trial, f_trial, seed=seed) winners.append(w) # Determine middle-quarter bounds upper = int(np.floor(N_trial * 0.625)) lower = int(np.ceil(N_trial * 0.375)) #print (N_trial, winners, lower, upper) middle_count = sum(1 for w in winners if w is not None and lower <= w <= upper) middle_fraction = middle_count / runs_per_cell if runs_per_cell > 0 else np.nan results.append({"N": N_trial, "majority_fraction": f_trial, "middle_fraction": middle_fraction}) df = pd.DataFrame(results) csv_path = f"sims/{ts}/middle_quarter_heatmap_raw.csv" df.to_csv(csv_path, index=False) print("Saved raw sweep CSV to:", csv_path) # # Pivot for heatmap # pivot = df.pivot(index="N", columns="majority_fraction", values="middle_fraction") # pivot = pivot.reindex(index=N_values, columns=sorted(majority_fraction_values)) # fig, ax = plt.subplots(figsize=(12, 8)) # #data_to_plot = pivot.values[::-1, :] # flip vertically # print (pivot.values) # im = ax.imshow(pivot.values, aspect='auto', origin='lower', extent=[min(majority_fraction_values), max(majority_fraction_values), min(N_values), max(N_values)]) # #ax.invert_yaxis() # #ax.imshow(pivot.values, aspect="auto", origin="lower", interpolation="nearest", cmap="viridis") # ax.set_xlabel("Majority fraction") # ax.set_ylabel("N (body size)") # ax.set_title("Fraction of winners in middle quarter") # ax.set_xticks(np.linspace(0, len(majority_fraction_values)-1, 6)) # tick_indices = np.linspace(0, len(majority_fraction_values)-1, 6, dtype=int) # maybe 6 ticks instead of 11 # xticklabels = [f"{majority_fraction_values[i]:.2f}" for i in tick_indices] # ax.set_xticklabels(xticklabels) # ax.set_yticks(np.linspace(0, len(N_values)-1, 10)) # ax.set_yticklabels([str(N_values[int(x*len(N_values)/10)]) for x in range(10)]) # cbar = fig.colorbar(im, ax=ax) # cbar.set_label("Middle-quarter win fraction") # plt.tight_layout() # png_path = f"sims/{ts}/middle_quarter_heatmap.png" # fig.savefig(png_path, dpi=300) # plt.close(fig) # print("Saved heatmap PNG to:", png_path) end_time = time.time() print(f"Completed sweep in {end_time - start_time:.1f}s") pivot = None return df, pivot # ------------------------- # Example usage # ------------------------- if __name__ == "__main__": # Sweeps N = 1..500 (maybe start smaller for testing) N_values = list(range(1, 200)) # for testing, smaller range; replace 51→501 for full sweep majority_fraction_values = [x/100 for x in range(50, 101)] # 0%,1%,...,100% RUNS_PER_CELL = 500 # adjust; higher gives smoother estimate ordinaryRun() #df_raw, pivot = heatmap_middle_quarter(N_values, majority_fraction_values, runs_per_cell=RUNS_PER_CELL) #print("Raw table head:\n", df_raw.head())