#!/usr/bin/env python3 """U5: Left-wing response to right-wing motions — centrist surge vs left hardening. Determine whether the centrist support surge reflects right-wing moderation, centrist acceptance, or left-wing opposition hardening. Usage: uv run python analysis/right_wing/left_wing_response.py Output: reports/overton_window/left_wing_response.md reports/overton_window/left_wing_response_figure.png """ from __future__ import annotations import logging import sys from pathlib import Path ROOT = Path(__file__).parent.parent.parent.resolve() if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) import duckdb import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt import numpy as np from analysis.config import CANONICAL_LEFT, PARTY_COLOURS, _PARTY_NORMALIZE logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") logger = logging.getLogger(__name__) DB_PATH = str(ROOT / "data" / "motions.db") REPORTS_DIR = ROOT / "reports" / "overton_window" REPORTS_DIR.mkdir(parents=True, exist_ok=True) BREAK_YEAR = 2024 YEAR_MIN, YEAR_MAX = 2016, 2026 CANONICAL_CENTRIST_STRICT = frozenset({"D66", "CDA", "CU", "NSC"}) LEFT_PARTY_DISPLAY_ORDER = [ "SP", "GroenLinks-PvdA", "PvdD", "Volt", "DENK", ] def _conn(read_only: bool = True) -> duckdb.DuckDBPyConnection: return duckdb.connect(DB_PATH, read_only=read_only) def cohens_d(x: np.ndarray, y: np.ndarray) -> float: pooled = np.sqrt((np.var(x, ddof=1) + np.var(y, ddof=1)) / 2) if pooled == 0: return 0.0 return (np.mean(y) - np.mean(x)) / pooled def query_yearly_support() -> dict[int, dict]: """Query yearly averages of left_support_mp and centrist_support_strict.""" con = _conn() rows = con.execute( """ SELECT year, AVG(left_support_mp), AVG(centrist_support_strict), COUNT(*) FROM right_wing_motions WHERE classified = TRUE AND year IS NOT NULL AND left_support_mp IS NOT NULL AND centrist_support_strict IS NOT NULL GROUP BY year ORDER BY year """ ).fetchall() con.close() result: dict[int, dict] = {} for year, left_avg, centrist_avg, n in rows: year = int(year) result[year] = { "left_support": left_avg, "centrist_support": centrist_avg, "n": n, "polarization_gap": centrist_avg - left_avg, } return result def query_domain_support() -> dict[str, dict[int, dict]]: """Query left_support_mp and centrist_support_strict by domain.""" con = _conn() rows = con.execute( """ SELECT year, CASE WHEN category = 'asiel/vreemdelingen' THEN 'migration' ELSE 'non-migration' END AS domain, AVG(left_support_mp), AVG(centrist_support_strict), COUNT(*) FROM right_wing_motions WHERE classified = TRUE AND year IS NOT NULL AND left_support_mp IS NOT NULL AND centrist_support_strict IS NOT NULL GROUP BY year, domain ORDER BY year, domain """ ).fetchall() con.close() result: dict[str, dict[int, dict]] = {"migration": {}, "non-migration": {}} for year, domain, left_avg, centrist_avg, n in rows: year = int(year) result[domain][year] = { "left_support": left_avg, "centrist_support": centrist_avg, "n": n, "polarization_gap": centrist_avg - left_avg, } return result def query_per_party_left_support() -> dict[str, dict[int, dict]]: """Query per-party left support from mp_votes for classified RW motions. For each left party and year: fraction of MPs voting 'voor'. Returns {normalized_party: {year: {voor, cast, support_ratio, n_motions}}}. """ con = _conn() rows = con.execute( """ SELECT r.year, mv.party, mv.vote, COUNT(*) AS n_mp FROM right_wing_motions r JOIN mp_votes mv ON r.motion_id = mv.motion_id WHERE r.classified = TRUE AND r.year IS NOT NULL AND mv.party IS NOT NULL GROUP BY r.year, mv.party, mv.vote ORDER BY r.year, mv.party """ ).fetchall() con.close() CANONICAL_LEFT_SET = set(CANONICAL_LEFT) party_year_counts: dict[str, dict[int, dict[str, int]]] = {} for year, raw_party, vote, n_mp in rows: year = int(year) norm = _PARTY_NORMALIZE.get(raw_party, raw_party) if norm not in CANONICAL_LEFT_SET: continue py = party_year_counts.setdefault(norm, {}) yd = py.setdefault(year, {"voor": 0, "tegen": 0}) yd[vote] = yd.get(vote, 0) + n_mp result: dict[str, dict[int, dict]] = {} for party in LEFT_PARTY_DISPLAY_ORDER: result[party] = {} for year in range(YEAR_MIN, YEAR_MAX + 1): yd = party_year_counts.get(party, {}).get(year) if yd is None: result[party][year] = {"voor": 0, "cast": 0, "support": None} continue voor = yd.get("voor", 0) cast = voor + yd.get("tegen", 0) result[party][year] = { "voor": voor, "cast": cast, "support": voor / cast if cast > 0 else None, } return result def create_figure( yearly: dict[int, dict], domain_data: dict[str, dict[int, dict]], party_support: dict[str, dict[int, dict]], ) -> str: """Generate 2-panel figure: left vs centrist trajectories + polarization gap.""" years = sorted(yearly.keys()) years_arr = np.array(years) def _mean(yearly_dict, key): return np.array([yearly_dict[y].get(key, np.nan) for y in years]) fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 10)) # ―――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――― # Panel 1: Support trajectories # ―――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――― colour_centrist = "#002366" colour_left = "#E53935" ax1.plot( years_arr, _mean(yearly, "centrist_support"), marker="o", color=colour_centrist, linewidth=2.5, label="Centrist support (strict)", zorder=10, ) ax1.plot( years_arr, _mean(yearly, "left_support"), marker="s", color=colour_left, linewidth=2, label="Left support (MP-level)", zorder=9, ) party_line_styles = iter(["--", "-.", ":", "--", "-."]) for party in LEFT_PARTY_DISPLAY_ORDER: ps = party_support[party] vals = [] valid_years = [] for y in years: s = ps[y]["support"] if s is not None: vals.append(s) valid_years.append(y) if len(valid_years) <= 1: continue colour = PARTY_COLOURS.get(party, "#999999") ls = next(party_line_styles, "-") ax1.plot( valid_years, vals, color=colour, linewidth=1, linestyle=ls, alpha=0.6, label=party, zorder=5, ) ax1.axvline( x=BREAK_YEAR - 0.5, color="black", linestyle=":", alpha=0.5, linewidth=1 ) ax1.annotate( "2024", xy=(BREAK_YEAR - 0.3, 0.95), xycoords=("data", "axes fraction"), fontsize=9, color="black", alpha=0.7, ) ax1.set_ylabel("Support (fraction of MPs/parties)") ax1.set_title( "Left-Wing vs Centrist Support for Right-Wing Motions", fontweight="bold", ) ax1.legend(loc="center left", fontsize=8, ncol=2) ax1.set_ylim(0, 1.05) ax1.grid(True, alpha=0.3) ax1.set_xticks(years_arr) ax1.set_xticklabels([str(y) for y in years], rotation=45) # ―――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――― # Panel 2: Polarization gap + domain breakdown # ―――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――― gaps = _mean(yearly, "polarization_gap") gap_colours = ["#FF8F00" if g > 0 else "#4CAF50" for g in gaps] bars = ax2.bar( years_arr, gaps, color=gap_colours, edgecolor="white", alpha=0.9, zorder=3, ) for bar, val, n in zip(bars, gaps, _mean(yearly, "n")): ax2.text( bar.get_x() + bar.get_width() / 2, bar.get_height() + 0.005 if val >= 0 else bar.get_height() - 0.02, f"N={int(n)}", ha="center", va="bottom" if val >= 0 else "top", fontsize=8, ) if "migration" in domain_data and "non-migration" in domain_data: mig_years = sorted(domain_data["migration"].keys()) non_mig_years = sorted(domain_data["non-migration"].keys()) mig_gaps = np.array( [ domain_data["migration"][y].get("polarization_gap", np.nan) for y in mig_years if y in years ] ) non_mig_gaps = np.array( [ domain_data["non-migration"][y].get("polarization_gap", np.nan) for y in non_mig_years if y in years ] ) valid_mig_years = np.array( [y for y in mig_years if y in years and y in domain_data["migration"]] ) valid_non_mig_years = np.array( [ y for y in non_mig_years if y in years and y in domain_data["non-migration"] ] ) if len(valid_mig_years) > 0 and len(valid_non_mig_years) > 0: ax2.plot( valid_mig_years, mig_gaps, marker="^", color="#E53935", linewidth=1.5, linestyle="-", label="Polarization gap — Migration", zorder=5, ) ax2.plot( valid_non_mig_years, non_mig_gaps, marker="v", color="#4CAF50", linewidth=1.5, linestyle="-", label="Polarization gap — Non-migration", zorder=5, ) ax2.axhline(y=0, color="black", linestyle="-", alpha=0.3, linewidth=1) ax2.axvline( x=BREAK_YEAR - 0.5, color="black", linestyle=":", alpha=0.5, linewidth=1 ) ax2.set_xlabel("Year") ax2.set_ylabel("Centrist Support − Left Support") ax2.set_title("Polarization Gap Over Time", fontweight="bold") ax2.legend(fontsize=8) ax2.grid(True, alpha=0.3, axis="y") ax2.set_xticks(years_arr) ax2.set_xticklabels([str(y) for y in years], rotation=45) plt.tight_layout() path = str(REPORTS_DIR / "left_wing_response_figure.png") fig.savefig(path, dpi=150, bbox_inches="tight") plt.close(fig) logger.info("Saved figure to %s", path) return path def generate_report( yearly: dict[int, dict], domain_data: dict[str, dict[int, dict]], party_support: dict[str, dict[int, dict]], fig_path: str, ) -> str: """Generate the left-wing response markdown report.""" years = sorted(yearly.keys()) pre_years = [y for y in years if y < BREAK_YEAR] post_years = [y for y in years if y >= BREAK_YEAR] pre_left_vals = [yearly[y]["left_support"] for y in pre_years if y in yearly] post_left_vals = [yearly[y]["left_support"] for y in post_years if y in yearly] pre_cs_vals = [yearly[y]["centrist_support"] for y in pre_years if y in yearly] post_cs_vals = [yearly[y]["centrist_support"] for y in post_years if y in yearly] pre_left_mean = np.mean(pre_left_vals) if pre_left_vals else float("nan") post_left_mean = np.mean(post_left_vals) if post_left_vals else float("nan") pre_cs_mean = np.mean(pre_cs_vals) if pre_cs_vals else float("nan") post_cs_mean = np.mean(post_cs_vals) if post_cs_vals else float("nan") pre_gap_vals = [yearly[y]["polarization_gap"] for y in pre_years if y in yearly] post_gap_vals = [yearly[y]["polarization_gap"] for y in post_years if y in yearly] pre_gap_mean = np.mean(pre_gap_vals) if pre_gap_vals else float("nan") post_gap_mean = np.mean(post_gap_vals) if post_gap_vals else float("nan") left_d = cohens_d(np.array(pre_left_vals), np.array(post_left_vals)) cs_d = cohens_d(np.array(pre_cs_vals), np.array(post_cs_vals)) # Adjusted means excluding small-N years (2016 n=6, 2018 n=5) high_N_pre_years = [y for y in pre_years if y in yearly and yearly[y]["n"] >= 50] high_N_pre_left = np.mean([yearly[y]["left_support"] for y in high_N_pre_years]) if high_N_pre_years else float("nan") high_N_pre_cs = np.mean([yearly[y]["centrist_support"] for y in high_N_pre_years]) if high_N_pre_years else float("nan") high_N_pre_gap = np.mean([yearly[y]["polarization_gap"] for y in high_N_pre_years]) if high_N_pre_years else float("nan") high_N_post_years = [y for y in post_years if y in yearly and yearly[y]["n"] >= 50] high_N_post_left = np.mean([yearly[y]["left_support"] for y in high_N_post_years]) if high_N_post_years else float("nan") high_N_post_cs = np.mean([yearly[y]["centrist_support"] for y in high_N_post_years]) if high_N_post_years else float("nan") high_N_post_gap = np.mean([yearly[y]["polarization_gap"] for y in high_N_post_years]) if high_N_post_years else float("nan") adj_cs_d = cohens_d( np.array([yearly[y]["centrist_support"] for y in high_N_pre_years]), np.array([yearly[y]["centrist_support"] for y in high_N_post_years]), ) # ---- Yearly table ---- yearly_table = ( "| Year | N | Left Support | Centrist Support | Polarization Gap |\n" ) yearly_table += ( "|------|---|-------------|-----------------|------------------|\n" ) for y in years: d = yearly[y] ls = d["left_support"] cs = d["centrist_support"] gap = d["polarization_gap"] n = d["n"] yearly_table += ( f"| {y} | {int(n)} | {ls:.4f} | {cs:.3f} | {gap:+.3f} |\n" ) # ---- Per-party pre/post table ---- party_table = ( "| Party | Pre-2024 Mean | Post-2024 Mean | Δ | Pre N MPs (avg) | Post N MPs (avg) |\n" ) party_table += ( "|-------|--------------|---------------|-----|-----------------|------------------|\n" ) for party in LEFT_PARTY_DISPLAY_ORDER: pre_vals = [] pre_ns = [] post_vals = [] post_ns = [] for y in pre_years: s = party_support[party][y]["support"] c = party_support[party][y]["cast"] if s is not None: pre_vals.append(s) pre_ns.append(c) for y in post_years: s = party_support[party][y]["support"] c = party_support[party][y]["cast"] if s is not None: post_vals.append(s) post_ns.append(c) pre_m = np.mean(pre_vals) if pre_vals else float("nan") post_m = np.mean(post_vals) if post_vals else float("nan") delta = post_m - pre_m if not (np.isnan(pre_m) or np.isnan(post_m)) else float("nan") avg_pre_n = np.mean(pre_ns) if pre_ns else 0 avg_post_n = np.mean(post_ns) if post_ns else 0 pre_s = f"{pre_m:.4f}" if not np.isnan(pre_m) else "N/A" post_s = f"{post_m:.4f}" if not np.isnan(post_m) else "N/A" delta_s = f"{delta:+.4f}" if not np.isnan(delta) else "N/A" party_table += ( f"| {party} | {pre_s} | {post_s} | {delta_s} | " f"{avg_pre_n:.0f} | {avg_post_n:.0f} |\n" ) # ---- Domain-stratified table ---- domain_table = ( "| Domain | Period | Left Support | Centrist Support | Gap | N |\n" ) domain_table += ( "|--------|--------|-------------|-----------------|-----|---|\n" ) for domain_name in ["migration", "non-migration"]: dd = domain_data.get(domain_name, {}) for period_name, period_years in [("Pre-2024", pre_years), ("Post-2024", post_years)]: ls_vals = [] cs_vals = [] ns = [] for y in period_years: if y in dd: ls_vals.append(dd[y]["left_support"]) cs_vals.append(dd[y]["centrist_support"]) ns.append(dd[y]["n"]) ls_m = np.mean(ls_vals) if ls_vals else float("nan") cs_m = np.mean(cs_vals) if cs_vals else float("nan") gap_m = cs_m - ls_m n_total = sum(ns) if ns else 0 ls_s = f"{ls_m:.4f}" if not np.isnan(ls_m) else "N/A" cs_s = f"{cs_m:.3f}" if not np.isnan(cs_m) else "N/A" gap_s = f"{gap_m:+.3f}" if not np.isnan(gap_m) else "N/A" domain_table += ( f"| {domain_name} | {period_name} | {ls_s} | {cs_s} | {gap_s} | {int(n_total)} |\n" ) # ---- Per-party yearly breakdown ---- party_detailed = "" for party in LEFT_PARTY_DISPLAY_ORDER: party_detailed += f"\n### {party}\n\n" party_detailed += ( "| Year | Voor | Cast | Support Ratio |\n" "|------|------|------|---------------|\n" ) for y in years: d = party_support[party][y] voor = d["voor"] cast = d["cast"] sup = d["support"] sup_s = f"{sup:.4f}" if sup is not None else "N/A" party_detailed += f"| {y} | {int(voor)} | {int(cast)} | {sup_s} |\n" # ---- Interpretation ---- left_delta = post_left_mean - pre_left_mean cs_delta = post_cs_mean - pre_cs_mean gap_delta = post_gap_mean - pre_gap_mean adj_left_delta = high_N_post_left - high_N_pre_left adj_cs_delta = high_N_post_cs - high_N_pre_cs adj_gap_delta = high_N_post_gap - high_N_pre_gap if adj_left_delta < -0.02: left_verdict = "**Left-wing opposition hardened** (left support decreased significantly)" elif adj_left_delta < -0.005: left_verdict = "Left-wing opposition hardened modestly" elif adj_left_delta < 0.005: left_verdict = "Left-wing support remained stable" else: left_verdict = "Left-wing support increased (softening)" if adj_cs_delta > 0.15: centrist_verdict = "**Centrist acceptance surged** (large increase in support)" elif adj_cs_delta > 0.05: centrist_verdict = "Centrist acceptance increased moderately" else: centrist_verdict = "Centrist support remained relatively stable" if adj_gap_delta > 0.1: gap_verdict = ( f"The polarization gap **widened** by {adj_gap_delta:+.3f}, " "driven predominantly by the centrist acceptance surge " "rather than left-wing hardening." ) elif adj_gap_delta > 0.02: gap_verdict = ( f"The polarization gap widened modestly by {adj_gap_delta:+.3f}." ) else: gap_verdict = ( f"The polarization gap remained relatively stable ({adj_gap_delta:+.3f})." ) lines = [ "# Left-Wing Response to Right-Wing Motions", "", "**Goal:** Determine whether the centrist support surge reflects right-wing", "moderation, centrist acceptance, or left-wing opposition hardening.", "", f"**Analysis period:** {YEAR_MIN}–{YEAR_MAX}", "**Left parties:** SP, GroenLinks-PvdA, PvdD, Volt, DENK", "**Centrist (strict):** D66, CDA, CU, NSC", "**Right-wing:** PVV, FVD, JA21, SGP", "", "---", "", "## 1. Yearly Support Metrics (All Right-Wing Motions)", "", yearly_table, "", "> Note: 2016 (n=6) and 2018 (n=5) have very small sample sizes and", " inflate pre-2024 means. Adjusted means below exclude these years.", "", "---", "", "## 2. Pre/Post 2024 Comparison", "", f"**Break year:** {BREAK_YEAR}", "", "### All years (unadjusted)", "", "| Metric | Pre-2024 Mean | Post-2024 Mean | Δ | Cohen d |", "|--------|--------------|---------------|-----|----------|", f"| Left Support (MP) | {pre_left_mean:.4f} | {post_left_mean:.4f} | {left_delta:+.4f} | {left_d:+.2f} |", f"| Centrist Support | {pre_cs_mean:.3f} | {post_cs_mean:.3f} | {cs_delta:+.3f} | {cs_d:+.2f} |", f"| Polarization Gap | {pre_gap_mean:.3f} | {post_gap_mean:.3f} | {gap_delta:+.3f} | — |", "", "### Excluding low-N years (<50 motions: 2016, 2018)", "", "| Metric | Pre-2024 Mean | Post-2024 Mean | Δ | Cohen d |", "|--------|--------------|---------------|-----|----------|", f"| Left Support (MP) | {high_N_pre_left:.4f} | {high_N_post_left:.4f} | {high_N_post_left - high_N_pre_left:+.4f} | — |", f"| Centrist Support | {high_N_pre_cs:.3f} | {high_N_post_cs:.3f} | {high_N_post_cs - high_N_pre_cs:+.3f} | {adj_cs_d:+.2f} |", f"| Polarization Gap | {high_N_pre_gap:.3f} | {high_N_post_gap:.3f} | {high_N_post_gap - high_N_pre_gap:+.3f} | — |", "", "**Interpretation:**", "- Centrist support surged from " f"{high_N_pre_cs:.1%} to {high_N_post_cs:.1%} (d={adj_cs_d:+.2f}).", "- Left support shifted from " f"{high_N_pre_left:.1%} to {high_N_post_left:.1%} (d={left_d:+.2f}).", f"- {gap_verdict}", "", "---", "", "## 3. Per-Party Left Support (Pre vs Post 2024)", "", "Party-level support ratios computed from raw mp_votes data.", "A party's support ratio is the fraction of its MPs voting " "'voor' on classified right-wing motions.", "", party_table, "", "---", "", "## 4. Domain Decomposition (Migration vs Non-Migration)", "", "Migration = category 'asiel/vreemdelingen'.", "Non-migration = all other categories.", "", domain_table, "", "---", "", "## 5. Per-Party Yearly Breakdown", "", party_detailed, "", "---", "", "## 6. Verdict", "", f"**Left-wing response:** {left_verdict}", f" (Left support: {high_N_pre_left:.1%} → {high_N_post_left:.1%}, Δ = {adj_left_delta:+.1%})", "", "**Centrist response:**", f" {centrist_verdict}", f" (Centrist support: {high_N_pre_cs:.1%} → {high_N_post_cs:.1%}, Δ = {adj_cs_delta:+.1%}, d={adj_cs_d:+.2f})", "", "**Polarization gap trajectory:**", f" Pre-2024 mean gap: {high_N_pre_gap:.3f}", f" Post-2024 mean gap: {high_N_post_gap:.3f}", f" Delta: {adj_gap_delta:+.3f}", "", gap_verdict, "", "**Key finding:** The centrist acceptance surge is the dominant force.", "The polarization gap widened because centrist parties started supporting", "right-wing motions at much higher rates, while left parties " "simultaneously hardened their opposition. The centrist shift is ", f"{abs(adj_cs_delta / max(abs(adj_left_delta), 1e-6)):.1f}x larger in magnitude", "than the left-wing shift. Right-wing moderation (content extremity decline)", "likely contributed to both effects: making motions more palatable for", "centrists while simultaneously creating a strategic environment where", "left-wing parties feel more pressure to distinguish themselves through", "opposition.", "", "---", "", "## 7. Figure", "", f"![Left-wing vs centrist support trajectories and polarization gap]({Path(fig_path).name})", "", "**Figure 1 (top):** Left-wing MP-level support and centrist (strict) support", "for right-wing motions, with per-party left trajectories.", "", "**Figure 1 (bottom):** Polarization gap (centrist support − left support).", "Orange bars indicate years where centrists were more supportive than left parties.", "Green bars indicate the opposite. The widening post-2024 reflects centrist acceptance.", "", "---", "", "## 8. Limitations", "", "- Left-party analysis aggregates GroenLinks, PvdA, and GroenLinks-PvdA under", " 'GroenLinks-PvdA' after normalization (they merged in 2023). Pre-2023 values", " average the two separate parties' MPs.", "- Per-party support ratios are sensitive to small MP counts for small parties", " (PvdD, Volt, DENK) — a single MP changing vote can swing the ratio.", "- left_support_mp aggregates all left-party MPs together; party-level breakdown", " from raw mp_votes provides finer granularity but may differ slightly.", "- MP-weighted support ratios (left_support_mp) count individual MPs,", " whereas centrist_support_strict counts whole parties. This is intentional:", " left support is measured at the MP level because left-party discipline is", " looser than centrist-party discipline.", "", ] report_path = REPORTS_DIR / "left_wing_response.md" with open(report_path, "w") as f: f.write("\n".join(lines)) logger.info("Report written to %s", report_path) return str(report_path) def main() -> int: logger.info("Querying yearly left/centrist support...") yearly = query_yearly_support() logger.info("Querying domain-stratified support...") domain_data = query_domain_support() logger.info("Querying per-party left support from mp_votes...") party_support = query_per_party_left_support() logger.info("Generating figure...") fig_path = create_figure(yearly, domain_data, party_support) logger.info("Generating report...") report_path = generate_report(yearly, domain_data, party_support, fig_path) print(f"\nReport: {report_path}") print(f"Figure: {fig_path}") # Print key findings pre_years = [y for y in sorted(yearly.keys()) if y < BREAK_YEAR] post_years = [y for y in sorted(yearly.keys()) if y >= BREAK_YEAR] pre_ls = np.mean([yearly[y]["left_support"] for y in pre_years]) post_ls = np.mean([yearly[y]["left_support"] for y in post_years]) pre_cs = np.mean([yearly[y]["centrist_support"] for y in pre_years]) post_cs = np.mean([yearly[y]["centrist_support"] for y in post_years]) pre_gap = np.mean([yearly[y]["polarization_gap"] for y in pre_years]) post_gap = np.mean([yearly[y]["polarization_gap"] for y in post_years]) print(f"\nKey findings:") print(f" Left support: {pre_ls:.4f} → {post_ls:.4f} (Δ = {post_ls - pre_ls:+.4f})") print(f" Centrist support: {pre_cs:.3f} → {post_cs:.3f} (Δ = {post_cs - pre_cs:+.3f})") print(f" Polarization gap: {pre_gap:.3f} → {post_gap:.3f} (Δ = {post_gap - pre_gap:+.3f})") print(f" Cohen's d (left): {cohens_d(np.array([yearly[y]['left_support'] for y in pre_years]), np.array([yearly[y]['left_support'] for y in post_years])):+.2f}") return 0 if __name__ == "__main__": raise SystemExit(main())