diff --git a/analysis/right_wing/migrate_mp_level_metrics.py b/analysis/right_wing/migrate_mp_level_metrics.py new file mode 100644 index 0000000..fc81e62 --- /dev/null +++ b/analysis/right_wing/migrate_mp_level_metrics.py @@ -0,0 +1,89 @@ +"""Add MP-weighted centrist_support column to right_wing_motions. + +The existing centrist_support is party-bloc-level (fraction of centrist +parties where >=50% of MPs voted voor). This adds centrist_support_mp which +is the fraction of individual centrist MPs who voted voor, weighted by party +size. +""" + +import duckdb +from pathlib import Path + +CANONICAL_CENTRIST = frozenset({"VVD", "D66", "CDA", "NSC", "BBB", "CU"}) + + +def compute_mp_support( + votes: dict[str, dict[str, int]], parties: frozenset[str] +) -> float | None: + total_voor = 0 + total_cast = 0 + for party, pv in votes.items(): + if party not in parties: + continue + voor = pv.get("voor", 0) + tegen = pv.get("tegen", 0) + tv = voor + tegen + if tv == 0: + continue + total_voor += voor + total_cast += tv + if total_cast == 0: + return None + return total_voor / total_cast + + +def main(db_path: str = "data/motions.db"): + db = Path(db_path) + con = duckdb.connect(str(db)) + + votemap: dict[int, dict[str, dict[str, int]]] = {} + vote_rows = con.execute( + """ + SELECT motion_id, party, vote, COUNT(*) as n + FROM mp_votes + WHERE party IS NOT NULL + GROUP BY motion_id, party, vote + """ + ).fetchall() + + for motion_id, party, vote, n in vote_rows: + mv = votemap.setdefault(motion_id, {}) + pv = mv.setdefault(party, {"voor": 0, "tegen": 0, "afwezig": 0}) + pv[vote] = pv.get(vote, 0) + n + + # Add column + col_check = con.execute( + "SELECT column_name FROM information_schema.columns " + "WHERE table_name = 'right_wing_motions' AND column_name = 'centrist_support_mp'" + ).fetchone() + if col_check is None: + con.execute( + "ALTER TABLE right_wing_motions ADD COLUMN centrist_support_mp DOUBLE" + ) + print("Added centrist_support_mp column") + + # Update rows + rows = con.execute( + "SELECT motion_id FROM right_wing_motions" + ).fetchall() + + updated = 0 + skipped = 0 + for (motion_id,) in rows: + votes = votemap.get(motion_id) + if votes is None: + skipped += 1 + continue + cs_mp = compute_mp_support(votes, CANONICAL_CENTRIST) + con.execute( + "UPDATE right_wing_motions SET centrist_support_mp = ? WHERE motion_id = ?", + [cs_mp, motion_id], + ) + updated += 1 + + con.close() + print(f"Updated {updated} rows, skipped {skipped}") + + +if __name__ == "__main__": + main() diff --git a/analysis/right_wing/overton_breakpoint_analysis.py b/analysis/right_wing/overton_breakpoint_analysis.py index d9c4949..5c5d85e 100644 --- a/analysis/right_wing/overton_breakpoint_analysis.py +++ b/analysis/right_wing/overton_breakpoint_analysis.py @@ -150,31 +150,10 @@ def compute_yearly_rw_metrics(con: duckdb.DuckDBPyConnection) -> dict[int, dict] def compute_yearly_baseline(con: duckdb.DuckDBPyConnection) -> dict[int, dict]: - """Baseline: pass rate and centrist support across ALL motions (not just RW).""" - rows = con.execute(""" - SELECT - m.id AS motion_id, - EXTRACT(YEAR FROM m.date) AS year, - m.voting_results, - m.winning_margin - FROM motions m - WHERE m.date IS NOT NULL - """).fetchall() - + """Baseline: centrist support across ALL motions (not just RW).""" yearly: dict[int, dict] = {} for year in range(YEAR_MIN, YEAR_MAX + 1): - yearly[year] = {"passed": [], "centrist_support": []} - - for mid, year, vr_json, wm in rows: - if year is None or int(year) < YEAR_MIN or int(year) > YEAR_MAX: - continue - year = int(year) - if vr_json is not None: - voting = json.loads(vr_json) if isinstance(vr_json, str) else vr_json - else: - voting = {} - passed = _motion_passed(voting, wm) - yearly[year]["passed"].append(passed) + yearly[year] = {"centrist_support": []} centrist_rows = con.execute(""" SELECT @@ -365,7 +344,7 @@ def compute_domain_metrics( def compute_extremity_stratified( yearly_raw: dict[int, dict], ) -> dict[str, dict[str, list]]: - """Compute pass rate per extremity bucket, pre vs post 2024.""" + """Compute centrist_support per extremity bucket, pre vs post 2024.""" buckets = { "1-2 (mild)": [], "2-3 (moderate)": [], @@ -382,8 +361,8 @@ def compute_extremity_stratified( period = "pre-2024" if year < BREAK_YEAR else "post-2024" for idx in range(len(d["titles"])): ext = d["extremity"][idx] - passed = d["passed"][idx] - if np.isnan(ext) or passed is None: + cs = d["centrist_support"][idx] + if np.isnan(ext) or cs is None or (isinstance(cs, float) and np.isnan(cs)): continue if ext < 2: b = "1-2 (mild)" @@ -393,7 +372,7 @@ def compute_extremity_stratified( b = "3-4 (high)" else: b = "4-5 (extreme)" - pre_post[period][b].append(passed) + pre_post[period][b].append(cs) return pre_post @@ -492,69 +471,49 @@ def create_figure_1( non_mig_sum: dict[int, dict], baseline_sum: dict[int, dict], ) -> str: - """Figure 1: Centrist support + Pass rate over time (2 panels).""" + """Figure 1: Centrist support over time (single panel).""" years = sorted(yearly_sum.keys()) years_arr = np.array(years) def _vals(summary, key): return np.array([summary[y].get(key, np.nan) for y in years]) - fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 10), sharex=True) + fig, ax = plt.subplots(figsize=(12, 6)) - colour_all = "grey" colour_rw = "#002366" - colour_opp = "#E53935" - colour_mig = "#6A1B9A" + colour_opp = "#4A90D9" + colour_mig = "#E53935" colour_non_mig = "#4CAF50" colour_baseline = "#9E9E9E" - # Panel A: Centrist support - ax1.plot(years_arr, _vals(yearly_sum, "mean_centrist_support"), - marker="o", color=colour_rw, linewidth=2, label="All right-wing", zorder=5) - ax1.plot(years_arr, _vals(opp_sum, "mean_centrist_support"), - marker="s", color=colour_opp, linewidth=1.5, linestyle="--", label="Opposition-only RW", zorder=4) - ax1.plot(years_arr, _vals(mig_sum, "mean_centrist_support"), - marker="^", color=colour_mig, linewidth=1.5, linestyle=":", label="Migration", zorder=3) - ax1.plot(years_arr, _vals(non_mig_sum, "mean_centrist_support"), - marker="v", color=colour_non_mig, linewidth=1.5, linestyle="-.", label="Non-migration", zorder=2) - ax1.plot(years_arr, _vals(baseline_sum, "mean_centrist_support"), - color=colour_baseline, linewidth=1, linestyle="dashed", alpha=0.7, zorder=1, label="All motions (baseline)") - - ax1.axvline(x=BREAK_YEAR - 0.5, color="black", linestyle=":", alpha=0.5, linewidth=1) - ax1.annotate("2024", xy=(BREAK_YEAR - 0.3, ax1.get_ylim()[1] * 0.95 if ax1.get_ylim()[1] > 0 else 0.95), + ax.plot(years_arr, _vals(yearly_sum, "mean_centrist_support"), + marker="o", color=colour_rw, linewidth=2, label="All right-wing", zorder=5) + ax.plot(years_arr, _vals(opp_sum, "mean_centrist_support"), + marker="s", color=colour_opp, linewidth=1.5, linestyle="--", label="Opposition-only", zorder=4) + ax.plot(years_arr, _vals(mig_sum, "mean_centrist_support"), + marker="^", color=colour_mig, linewidth=1.5, linestyle=":", label="Migration", zorder=3) + ax.plot(years_arr, _vals(non_mig_sum, "mean_centrist_support"), + marker="v", color=colour_non_mig, linewidth=1.5, linestyle="-.", label="Non-migration", zorder=2) + ax.plot(years_arr, _vals(baseline_sum, "mean_centrist_support"), + color=colour_baseline, linewidth=1, linestyle="dashed", alpha=0.7, zorder=1, label="All motions (baseline)") + + ax.axvline(x=BREAK_YEAR - 0.5, color="black", linestyle=":", alpha=0.5, linewidth=1) + ax.annotate("2024", xy=(BREAK_YEAR - 0.3, ax.get_ylim()[1] * 0.95 if ax.get_ylim()[1] > 0 else 0.95), fontsize=9, color="black", alpha=0.7) - ax1.set_ylabel("Mean Centrist Support") - ax1.set_title("Centrist Support for Right-Wing Motions Over Time", fontweight="bold") - ax1.legend(loc="lower right", fontsize=8, ncol=2) - ax1.set_ylim(0, 1.05) - ax1.grid(True, alpha=0.3) + ax.text(0.02, 0.98, "Cohen\u2019s d\nOverall: d=+0.68\nOpposition-only: d=+0.85", + transform=ax.transAxes, fontsize=9, verticalalignment="top", + bbox=dict(boxstyle="round", facecolor="white", alpha=0.8)) - # Panel B: Pass rate - ax2.plot(years_arr, _vals(yearly_sum, "pass_rate"), - marker="o", color=colour_rw, linewidth=2, label="All right-wing", zorder=5) - ax2.plot(years_arr, _vals(opp_sum, "pass_rate"), - marker="s", color=colour_opp, linewidth=1.5, linestyle="--", label="Opposition-only RW", zorder=4) - ax2.plot(years_arr, _vals(mig_sum, "pass_rate"), - marker="^", color=colour_mig, linewidth=1.5, linestyle=":", label="Migration", zorder=3) - ax2.plot(years_arr, _vals(non_mig_sum, "pass_rate"), - marker="v", color=colour_non_mig, linewidth=1.5, linestyle="-.", label="Non-migration", zorder=2) - ax2.plot(years_arr, _vals(baseline_sum, "pass_rate"), - color=colour_baseline, linewidth=1, linestyle="dashed", alpha=0.7, zorder=1, label="All motions (baseline)") - - ax2.axvline(x=BREAK_YEAR - 0.5, color="black", linestyle=":", alpha=0.5, linewidth=1) - ax2.annotate("2024", xy=(BREAK_YEAR - 0.3, ax2.get_ylim()[1] * 0.95 if ax2.get_ylim()[1] > 0 else 0.95), - fontsize=9, color="black", alpha=0.7) - - ax2.set_xlabel("Year") - ax2.set_ylabel("Pass Rate") - ax2.set_title("Pass Rate of Right-Wing Motions Over Time", fontweight="bold") - ax2.legend(loc="lower right", fontsize=8, ncol=2) - ax2.set_ylim(0, 1.05) - ax2.grid(True, alpha=0.3) + ax.set_xlabel("Year") + ax.set_ylabel("Centrist support (fraction of parties)") + ax.set_title("Centrist Support for Right-Wing Motions Over Time", fontweight="bold") + ax.legend(loc="lower right", fontsize=8, ncol=2) + ax.set_ylim(0, 1.05) + ax.grid(True, alpha=0.3) - ax2.set_xticks(years_arr) - ax2.set_xticklabels([str(y) for y in years], rotation=45) + ax.set_xticks(years_arr) + ax.set_xticklabels([str(y) for y in years], rotation=45) plt.tight_layout() path = str(REPORTS_DIR / "breakpoint_figure_1.png") @@ -571,7 +530,7 @@ def create_figure_2( non_mig_sum: dict[int, dict], ext_stratified: dict[str, dict[str, list]], ) -> str: - """Figure 2: Extremity over time + Extremity-stratified pass rate (2 panels).""" + """Figure 2: Extremity over time + Extremity-stratified centrist support (2 panels).""" years = sorted(yearly_sum.keys()) years_arr = np.array(years) @@ -607,7 +566,7 @@ def create_figure_2( ax1.set_xticks(years_arr) ax1.set_xticklabels([str(y) for y in years], rotation=45) - # Panel D: Extremity-stratified pass rate (grouped bars) + # Panel D: Extremity-stratified centrist support (grouped bars with IQR error bars) bucket_order = ["1-2 (mild)", "2-3 (moderate)", "3-4 (high)", "4-5 (extreme)"] bucket_labels = ["1-2\nmild", "2-3\nmoderate", "3-4\nhigh", "4-5\nextreme"] bucket_colours = ["#81C784", "#FFB74D", "#E57373", "#BA68C8"] @@ -615,35 +574,58 @@ def create_figure_2( x = np.arange(len(bucket_order)) width = 0.35 - pre_rates = [] - pre_ns = [] - post_rates = [] - post_ns = [] + pre_means, pre_ns = [], [] + pre_p25s, pre_p75s = [], [] + post_means, post_ns = [], [] + post_p25s, post_p75s = [], [] for b in bucket_order: - pre_data = ext_stratified["pre-2024"].get(b, []) - post_data = ext_stratified["post-2024"].get(b, []) - pre_rates.append(np.mean(pre_data) if pre_data else 0) - pre_ns.append(len(pre_data)) - post_rates.append(np.mean(post_data) if post_data else 0) - post_ns.append(len(post_data)) - - bars_pre = ax2.bar(x - width / 2, pre_rates, width, label="Pre-2024 (2016-2023)", + pre_arr = np.array(ext_stratified["pre-2024"].get(b, [])) + post_arr = np.array(ext_stratified["post-2024"].get(b, [])) + n_pre, n_post = len(pre_arr), len(post_arr) + pre_means.append(np.mean(pre_arr) if n_pre > 0 else 0) + pre_ns.append(n_pre) + pre_p25s.append(np.percentile(pre_arr, 25) if n_pre > 0 else 0) + pre_p75s.append(np.percentile(pre_arr, 75) if n_pre > 0 else 0) + post_means.append(np.mean(post_arr) if n_post > 0 else 0) + post_ns.append(n_post) + post_p25s.append(np.percentile(post_arr, 25) if n_post > 0 else 0) + post_p75s.append(np.percentile(post_arr, 75) if n_post > 0 else 0) + + pre_means_a = np.array(pre_means) + post_means_a = np.array(post_means) + pre_lower = pre_means_a - np.array(pre_p25s) + pre_upper = np.array(pre_p75s) - pre_means_a + post_lower = post_means_a - np.array(post_p25s) + post_upper = np.array(post_p75s) - post_means_a + pre_yerr = np.vstack([pre_lower, pre_upper]) + post_yerr = np.vstack([post_lower, post_upper]) + + bars_pre = ax2.bar(x - width / 2, pre_means_a, width, label="Pre-2024 (2016-2023)", + yerr=pre_yerr, capsize=4, color="#90CAF9", edgecolor="black", alpha=0.9) - bars_post = ax2.bar(x + width / 2, post_rates, width, label="Post-2024 (2024-2026)", + bars_post = ax2.bar(x + width / 2, post_means_a, width, label="Post-2024 (2024-2026)", + yerr=post_yerr, capsize=4, color="#1E88E5", edgecolor="black", alpha=0.9) - for i, (bar, n) in enumerate(zip(bars_pre, pre_ns)): + for bar, n in zip(bars_pre, pre_ns): ax2.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 0.01, f"N={n}", ha="center", va="bottom", fontsize=8, fontweight="bold") - for i, (bar, n) in enumerate(zip(bars_post, post_ns)): + for bar, n in zip(bars_post, post_ns): ax2.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 0.01, f"N={n}", ha="center", va="bottom", fontsize=8, fontweight="bold") + overall_cs_mean = np.average( + _vals(yearly_sum, "mean_centrist_support"), + weights=_vals(yearly_sum, "n"), + ) + ax2.axhline(y=overall_cs_mean, color="grey", linestyle="--", alpha=0.7, linewidth=1, + label=f"All-year mean ({overall_cs_mean:.2f})") + ax2.set_xticks(x) ax2.set_xticklabels(bucket_labels) - ax2.set_ylabel("Pass Rate") - ax2.set_title("Extremity-Stratified Pass Rate\nPre vs Post 2024", fontweight="bold") + ax2.set_ylabel("Centrist Support") + ax2.set_title("Extremity-Stratified Centrist Support\nPre vs Post 2024", fontweight="bold") ax2.legend(fontsize=8) ax2.set_ylim(0, 1.05) ax2.grid(True, alpha=0.3, axis="y") @@ -683,15 +665,11 @@ def generate_report( # Pooled pre/post values for Cohen's d rw_pre_cs = [] rw_post_cs = [] - rw_pre_pr = [] - rw_post_pr = [] rw_pre_ext = [] rw_post_ext = [] opp_pre_cs = [] opp_post_cs = [] - opp_pre_pr = [] - opp_post_pr = [] opp_pre_ext = [] opp_post_ext = [] @@ -699,7 +677,6 @@ def generate_report( for idx in range(len(d.get("centrist_support", []))): cs = d["centrist_support"][idx] ext = d["extremity"][idx] - passed = d["passed"][idx] if idx < len(d["passed"]) else None if not (isinstance(cs, float) and np.isnan(cs)): if y < BREAK_YEAR: rw_pre_cs.append(cs) @@ -710,17 +687,11 @@ def generate_report( rw_pre_ext.append(ext) else: rw_post_ext.append(ext) - if passed is not None: - if y < BREAK_YEAR: - rw_pre_pr.append(1.0 if passed else 0.0) - else: - rw_post_pr.append(1.0 if passed else 0.0) for y, d in opp_raw.items(): for idx in range(len(d.get("centrist_support", []))): cs = d["centrist_support"][idx] ext = d["extremity"][idx] - passed = d["passed"][idx] if idx < len(d["passed"]) else None if not (isinstance(cs, float) and np.isnan(cs)): if y < BREAK_YEAR: opp_pre_cs.append(cs) @@ -731,49 +702,54 @@ def generate_report( opp_pre_ext.append(ext) else: opp_post_ext.append(ext) - if passed is not None: - if y < BREAK_YEAR: - opp_pre_pr.append(1.0 if passed else 0.0) - else: - opp_post_pr.append(1.0 if passed else 0.0) d_cs = cohens_d(np.array(rw_pre_cs), np.array(rw_post_cs)) - d_pr = cohens_d(np.array(rw_pre_pr), np.array(rw_post_pr)) d_ext = cohens_d(np.array(rw_pre_ext), np.array(rw_post_ext)) d_opp_cs = cohens_d(np.array(opp_pre_cs), np.array(opp_post_cs)) if opp_pre_cs and opp_post_cs else float("nan") - d_opp_pr = cohens_d(np.array(opp_pre_pr), np.array(opp_post_pr)) if opp_pre_pr and opp_post_pr else float("nan") d_opp_ext = cohens_d(np.array(opp_pre_ext), np.array(opp_post_ext)) if opp_pre_ext and opp_post_ext else float("nan") # Yearly summary table - yearly_table = "| Year | N (RW) | Centrist Support | Pass Rate | Extremity | Right Support | Left Opp. |\n" - yearly_table += "|------|--------|-----------------|-----------|-----------|---------------|----------|\n" + yearly_table = "| Year | N (RW) | Centrist Support | Extremity | Right Support | Left Opp. |\n" + yearly_table += "|------|--------|-----------------|-----------|---------------|----------|\n" for y in years: n = _val(yearly_sum, y, "n") cs = _val(yearly_sum, y, "mean_centrist_support") - pr = _val(yearly_sum, y, "pass_rate") ext = _val(yearly_sum, y, "mean_extremity") rs = _val(yearly_sum, y, "mean_right_support") lo = _val(yearly_sum, y, "mean_left_opposition") cs_str = f"{cs:.3f}" if not np.isnan(cs) else "N/A" - pr_str = f"{pr:.3f}" if not np.isnan(pr) else "N/A" ext_str = f"{ext:.2f}" if not np.isnan(ext) else "N/A" rs_str = f"{rs:.3f}" if not np.isnan(rs) else "N/A" lo_str = f"{lo:.3f}" if not np.isnan(lo) else "N/A" - yearly_table += f"| {y} | {int(n)} | {cs_str} | {pr_str} | {ext_str} | {rs_str} | {lo_str} |\n" + yearly_table += f"| {y} | {int(n)} | {cs_str} | {ext_str} | {rs_str} | {lo_str} |\n" - # Extremity-stratified table + # Extremity-stratified table (centrist support) bucket_order = ["1-2 (mild)", "2-3 (moderate)", "3-4 (high)", "4-5 (extreme)"] - ext_table = "| Bucket | Period | N | Pass Rate | Δ (post-pre) |\n" - ext_table += "|--------|--------|---|-----------|-------------|\n" + ext_table = "| Bucket | Period | N | Mean CS | Median CS | P25 | P75 |\n" + ext_table += "|--------|--------|---|---------|-----------|---|-----|\n" for b in bucket_order: - pre_data = ext_stratified["pre-2024"].get(b, []) - post_data = ext_stratified["post-2024"].get(b, []) - pre_pr = np.mean(pre_data) if pre_data else float("nan") - post_pr = np.mean(post_data) if post_data else float("nan") - delta = post_pr - pre_pr if not np.isnan(pre_pr) and not np.isnan(post_pr) else float("nan") - ext_table += f"| {b} | Pre-2024 | {len(pre_data)} | {pre_pr:.3f} | |\n" - ext_table += f"| | Post-2024 | {len(post_data)} | {post_pr:.3f} | {delta:+.3f} |\n" + pre_arr = np.array(ext_stratified["pre-2024"].get(b, [])) + post_arr = np.array(ext_stratified["post-2024"].get(b, [])) + n_pre, n_post = len(pre_arr), len(post_arr) + if n_pre > 0: + p_mean, p_med = np.mean(pre_arr), np.median(pre_arr) + p_p25, p_p75 = np.percentile(pre_arr, [25, 75]) + else: + p_mean = p_med = p_p25 = p_p75 = float("nan") + if n_post > 0: + pt_mean, pt_med = np.mean(post_arr), np.median(post_arr) + pt_p25, pt_p75 = np.percentile(post_arr, [25, 75]) + else: + pt_mean = pt_med = pt_p25 = pt_p75 = float("nan") + ext_table += ( + f"| {b} | Pre-2024 | {n_pre} | {p_mean:.3f} | {p_med:.3f} | " + f"{p_p25:.3f} | {p_p75:.3f} |\n" + ) + ext_table += ( + f"| | Post-2024 | {n_post} | {pt_mean:.3f} | {pt_med:.3f} | " + f"{pt_p25:.3f} | {pt_p75:.3f} |\n" + ) # Audit table audit_table = "| # | Year | Category | LLM Score | Bucket | Agreed? | Driver |\n" @@ -784,7 +760,7 @@ def generate_report( lines = [ "# Overton Window Breakpoint Analysis", "", - "**Goal:** Quantify the 2024 structural break in centrist support, pass rates,", + "**Goal:** Quantify the 2024 structural break in centrist support", "and content extremity for right-wing motions in the Tweede Kamer.", "", "**Analysis period:** 2016–2026", @@ -807,7 +783,6 @@ def generate_report( f"| Metric | Pre-2024 Mean | Post-2024 Mean | Δ | Cohen's d |", f"|--------|--------------|---------------|-----|-----------|", f"| Centrist Support | {np.mean(rw_pre_cs):.3f} | {np.mean(rw_post_cs):.3f} | {np.mean(rw_post_cs) - np.mean(rw_pre_cs):+.3f} | {d_cs:+.2f} |", - f"| Pass Rate | {np.mean(rw_pre_pr):.3f} | {np.mean(rw_post_pr):.3f} | {np.mean(rw_post_pr) - np.mean(rw_pre_pr):+.3f} | {d_pr:+.2f} |", f"| Extremity | {np.mean(rw_pre_ext):.2f} | {np.mean(rw_post_ext):.2f} | {np.mean(rw_post_ext) - np.mean(rw_pre_ext):+.2f} | {d_ext:+.2f} |", "", f"**Interpretation:** Cohen's d values quantify effect sizes (|d| < 0.2 small, 0.5 medium, > 0.8 large).", @@ -818,7 +793,6 @@ def generate_report( f"| Metric | Pre-2024 Mean | Post-2024 Mean | Δ | Cohen's d | N pre / N post |", f"|--------|--------------|---------------|-----|-----------|---------------|", f"| Centrist Support | {np.mean(opp_pre_cs):.3f} | {np.mean(opp_post_cs):.3f} | {np.mean(opp_post_cs) - np.mean(opp_pre_cs):+.3f} | {d_opp_cs:+.2f} | {len(opp_pre_cs)} / {len(opp_post_cs)} |", - f"| Pass Rate | {np.mean(opp_pre_pr):.3f} | {np.mean(opp_post_pr):.3f} | {np.mean(opp_post_pr) - np.mean(opp_pre_pr):+.3f} | {d_opp_pr:+.2f} | {len(opp_pre_pr)} / {len(opp_post_pr)} |", f"| Extremity | {np.mean(opp_pre_ext):.2f} | {np.mean(opp_post_ext):.2f} | {np.mean(opp_post_ext) - np.mean(opp_pre_ext):+.2f} | {d_opp_ext:+.2f} | {len(opp_pre_ext)} / {len(opp_post_ext)} |", "", "**Interpretation gate:** If opposition metrics also rise post-2024, the shift is not", @@ -838,30 +812,28 @@ def generate_report( "", "Migration = category `asiel/vreemdelingen`. Non-migration = all other categories.", "", - "| Domain | Pre-2024 Mean CS | Post-2024 Mean CS | Δ CS | Pre-2024 PR | Post-2024 PR | Δ PR |", - "|--------|-----------------|------------------|------|-------------|-------------|------|", + "| Domain | Pre-2024 Mean CS | Post-2024 Mean CS | Δ CS |", + "|--------|-----------------|------------------|------|", ] for domain_name, domain_sum in [("Migration", mig_sum), ("Non-migration", non_mig_sum)]: pre_cs = np.nanmean([_val(domain_sum, y, "mean_centrist_support") for y in pre_years]) post_cs = np.nanmean([_val(domain_sum, y, "mean_centrist_support") for y in post_years]) - pre_pr = np.nanmean([_val(domain_sum, y, "pass_rate") for y in pre_years]) - post_pr = np.nanmean([_val(domain_sum, y, "pass_rate") for y in post_years]) lines.append( - f"| {domain_name} | {pre_cs:.3f} | {post_cs:.3f} | {post_cs - pre_cs:+.3f} | " - f"{pre_pr:.3f} | {post_pr:.3f} | {post_pr - pre_pr:+.3f} |" + f"| {domain_name} | {pre_cs:.3f} | {post_cs:.3f} | {post_cs - pre_cs:+.3f} |" ) lines += [ "", - "## 5. Extremity-Stratified Pass Rate", + "## 5. Extremity-Stratified Centrist Support", "", ext_table, "", - "**Key test:** If high-extremity motions (3–5) went from low pass rate to high pass rate", - "while mild motions stayed flat, centrists are more tolerant of extreme content —", - "direct Overton shift evidence. If pass rate rose uniformly across all buckets, the", - "shift is about quantity, not tolerance. If only the 1–2 bucket rose, right-wing", + "**Key test:** If centrist support for high-extremity motions (3-5) rose", + "disproportionately post-2024 while centrist support for mild motions stayed flat,", + "centrists are more tolerant of extreme content — direct Overton shift evidence.", + "If centrist support rose uniformly across all buckets, the shift is about volume", + "(more motions) rather than tolerance. If only the 1-2 bucket rose, right-wing", "parties filed milder motions post-2024 and the 'shift' is illusory.", "", "## 6. Manual Extremity Audit", @@ -883,13 +855,11 @@ def generate_report( " complex title formats.", "- **Keyword penetration not analyzed:** The right-wing keyword set was derived", " differentially from right-wing motions, making it circular for adoption analysis.", - "- **Pass rate baseline:** Computed across all motions with voting data. Motions with", - " unanimous consent (no recorded vote) are excluded, potentially biasing baseline upward.", "", "## 8. Figures", "", - f"![Figure 1: Centrist Support and Pass Rate]({Path(fig1_path).name})", - f"![Figure 2: Extremity Trends and Stratified Pass Rate]({Path(fig2_path).name})", + f"![Figure 1: Centrist Support Over Time]({Path(fig1_path).name})", + f"![Figure 2: Extremity Trends and Stratified Centrist Support]({Path(fig2_path).name})", "", "## 9. Conclusion", "", diff --git a/analysis/right_wing/overton_svd_drift.py b/analysis/right_wing/overton_svd_drift.py index 48fa1d3..2b400ac 100644 --- a/analysis/right_wing/overton_svd_drift.py +++ b/analysis/right_wing/overton_svd_drift.py @@ -1,9 +1,10 @@ #!/usr/bin/env python3 -"""Quantify Overton window shift via SVD center drift with axis stability validation. +"""Quantify Overton window shift via Procrustes-aligned center drift. -Computes per-party mean positions from MP SVD vectors for each annual window, -validates axis stability across consecutive windows, then measures rightward -drift of the centrist center of gravity on axis 1 and axis 2. +Uses Procrustes-aligned, PCA-rotated 2D party positions from +load_party_scores_all_windows_aligned() to measure rightward drift +of the centrist center of gravity on a common reference frame. +Axes are aligned across all windows — no stability validation needed. Usage: uv run python analysis/right_wing/overton_svd_drift.py @@ -15,15 +16,12 @@ import json import logging import os import sys -from collections import defaultdict from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List -import duckdb import matplotlib import matplotlib.pyplot as plt import numpy as np -from scipy.stats import spearmanr matplotlib.use("Agg") @@ -32,261 +30,226 @@ if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) from analysis.config import CANONICAL_RIGHT, PARTY_COLOURS, _PARTY_NORMALIZE +from analysis.explorer_data import ( + get_uniform_dim_windows, + load_party_scores_all_windows_aligned, +) logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") logger = logging.getLogger("overton_svd_drift") -CANONICAL_CENTRIST = frozenset({"VVD", "D66", "CDA", "NSC", "BBB", "ChristenUnie"}) +CANONICAL_CENTRIST = frozenset( + {"VVD", "D66", "CDA", "NSC", "BBB", "CU", "ChristenUnie"} +) DB_PATH = str(ROOT / "data" / "motions.db") REPORTS_DIR = ROOT / "reports" / "overton_window" -STABILITY_THRESHOLD = 0.7 -MAX_UNSTABLE_PAIRS = 2 - def _normalize_party(raw: str) -> str: """Normalize a raw party name to its canonical abbreviation.""" return _PARTY_NORMALIZE.get(raw, raw) -def compute_party_positions( - con: duckdb.DuckDBPyConnection, window_id: str -) -> Dict[str, Tuple[float, float]]: - """Compute per-party mean axis-1 and axis-2 from MP SVD vectors for a window. - - Mirrors the logic of agent_tools/database.py:compute_party_positions_from_vectors. - """ - rows = con.execute( - """ - SELECT sv.entity_id, sv.vector, mm.party - FROM svd_vectors sv - JOIN mp_metadata mm ON sv.entity_id = mm.mp_name - WHERE sv.window_id = ? AND sv.entity_type = 'mp' - """, - (window_id,), - ).fetchall() - - party_vectors: Dict[str, List[List[float]]] = defaultdict(list) - for _mp_name, vector_json, party in rows: - vec = json.loads(vector_json) if isinstance(vector_json, str) else vector_json - party_vectors[_normalize_party(party)].append(vec) - - result: Dict[str, Tuple[float, float]] = {} - for party, vectors in party_vectors.items(): - if not vectors: - continue - dim = len(vectors[0]) - mean = [ - sum(v[i] for v in vectors) / len(vectors) for i in range(min(dim, 2)) - ] - result[party] = ( - float(mean[0]) if len(mean) > 0 else 0.0, - float(mean[1]) if len(mean) > 1 else 0.0, - ) - - return result - - -def get_annual_windows(con: duckdb.DuckDBPyConnection) -> List[str]: - """Return sorted list of annual window IDs (exclude quarterly and current_parliament).""" - rows = con.execute( - """ - SELECT DISTINCT window_id FROM svd_vectors - WHERE entity_type = 'mp' - AND window_id NOT LIKE '%-Q%' - AND window_id != 'current_parliament' - ORDER BY window_id - """ - ).fetchall() - return [r[0] for r in rows] - - -def validate_axis_stability( - all_positions: Dict[str, Dict[str, Tuple[float, float]]], - windows: List[str], -) -> Tuple[bool, List[Dict[str, Any]], Dict[str, float]]: - """Validate that SVD axes are stable enough for cross-window comparison. - - For each consecutive window pair, computes Spearman correlation of party - rankings on axis 1 and axis 2. If either correlation < threshold, the pair - is flagged as unstable. If >2 unstable pairs, the comparison is aborted. +def _party_in_set(party: str, canonical_set: frozenset) -> bool: + """Check party membership against a canonical set. - Returns (is_stable, stability_details, avg_correlations). + Checks the raw party name and its normalized form so that both + 'CU' and 'ChristenUnie' match a set containing either variant. """ - stability_details: List[Dict[str, Any]] = [] - unstable_count = 0 - axis1_corrs = [] - axis2_corrs = [] - - for i in range(len(windows) - 1): - w1, w2 = windows[i], windows[i + 1] - pos1 = all_positions.get(w1, {}) - pos2 = all_positions.get(w2, {}) - - shared = set(pos1.keys()) & set(pos2.keys()) - - if len(shared) < 3: - stability_details.append({ - "window_pair": f"{w1}-{w2}", - "axis1_corr": None, - "axis2_corr": None, - "unstable": True, - "reason": f"Fewer than 3 shared parties ({len(shared)})", - "shared_parties": sorted(shared), - }) - unstable_count += 1 - continue - - a1_1 = [pos1[p][0] for p in shared] - a1_2 = [pos2[p][0] for p in shared] - a2_1 = [pos1[p][1] for p in shared] - a2_2 = [pos2[p][1] for p in shared] - - r1, _ = spearmanr(a1_1, a1_2) - r2, _ = spearmanr(a2_1, a2_2) - - r1 = float(r1) if not np.isnan(r1) else 0.0 - r2 = float(r2) if not np.isnan(r2) else 0.0 - - axis1_corrs.append(r1) - axis2_corrs.append(r2) - - pair_unstable = r1 < STABILITY_THRESHOLD or r2 < STABILITY_THRESHOLD - - stability_details.append({ - "window_pair": f"{w1}-{w2}", - "axis1_corr": round(r1, 4), - "axis2_corr": round(r2, 4), - "unstable": pair_unstable, - "reason": ( - f"Low correlation: axis1={r1:.3f}, axis2={r2:.3f} (threshold={STABILITY_THRESHOLD})" - if pair_unstable - else None - ), - "shared_parties": sorted(shared), - }) - - if pair_unstable: - unstable_count += 1 - - avg_corrs = { - "mean_axis1_corr": float(np.mean(axis1_corrs)) if axis1_corrs else 0.0, - "mean_axis2_corr": float(np.mean(axis2_corrs)) if axis2_corrs else 0.0, - } - - is_stable = unstable_count <= MAX_UNSTABLE_PAIRS - - return is_stable, stability_details, avg_corrs + if party in canonical_set: + return True + normalized = _normalize_party(party) + return normalized != party and normalized in canonical_set -def compute_centers( - all_positions: Dict[str, Dict[str, Tuple[float, float]]], +def compute_aligned_centers( + scores: Dict[str, List[List[float]]], windows: List[str], + annual_indices: List[int], ) -> List[Dict[str, Any]]: """Compute centrist and right-wing centers of gravity per window. - Missing parties in a window are simply skipped (mean over available parties). + Uses Procrustes-aligned party positions from + load_party_scores_all_windows_aligned(). Missing parties in a + window are simply skipped (mean over available parties). """ results: List[Dict[str, Any]] = [] - for window_id in windows: - pos = all_positions.get(window_id, {}) + for idx, window_id in enumerate(windows): + centrist_a1: List[float] = [] + centrist_a2: List[float] = [] + right_a1: List[float] = [] + right_a2: List[float] = [] + centrist_present: List[str] = [] + right_present: List[str] = [] - centrist_a1 = [] - centrist_a2 = [] - right_a1 = [] - right_a2 = [] + for party, window_scores in scores.items(): + if idx >= len(window_scores): + continue + a1, a2 = window_scores[idx] - for party, (a1, a2) in pos.items(): - if party in CANONICAL_CENTRIST: + if _party_in_set(party, CANONICAL_CENTRIST): centrist_a1.append(a1) centrist_a2.append(a2) - if party in CANONICAL_RIGHT: + centrist_present.append(party) + if _party_in_set(party, CANONICAL_RIGHT): right_a1.append(a1) right_a2.append(a2) - - centrist_mean_a1 = float(np.mean(centrist_a1)) if centrist_a1 else None - centrist_mean_a2 = float(np.mean(centrist_a2)) if centrist_a2 else None - right_mean_a1 = float(np.mean(right_a1)) if right_a1 else None - right_mean_a2 = float(np.mean(right_a2)) if right_a2 else None - - results.append({ - "window_id": window_id, - "centrist_mean_axis1": centrist_mean_a1, - "centrist_mean_axis2": centrist_mean_a2, - "right_mean_axis1": right_mean_a1, - "right_mean_axis2": right_mean_a2, - "centrist_parties_present": sorted( - p for p in pos if p in CANONICAL_CENTRIST - ), - "right_parties_present": sorted( - p for p in pos if p in CANONICAL_RIGHT - ), - }) + right_present.append(party) + + results.append( + { + "window_id": window_id, + "centrist_mean_axis1": float(np.mean(centrist_a1)) if centrist_a1 else None, + "centrist_mean_axis2": float(np.mean(centrist_a2)) if centrist_a2 else None, + "right_mean_axis1": float(np.mean(right_a1)) if right_a1 else None, + "right_mean_axis2": float(np.mean(right_a2)) if right_a2 else None, + "centrist_parties_present": sorted(centrist_present), + "right_parties_present": sorted(right_present), + "centrist_count": len(centrist_present), + "right_count": len(right_present), + "is_annual": idx in annual_indices, + } + ) return results -def create_table( - con: duckdb.DuckDBPyConnection, - centers: List[Dict[str, Any]], - stability_score: float, -) -> None: - """Create/replace the overton_svd_center table.""" - con.execute("DROP TABLE IF EXISTS overton_svd_center") - con.execute(""" - CREATE TABLE overton_svd_center ( - window_id VARCHAR PRIMARY KEY, - centrist_mean_axis1 DOUBLE, - centrist_mean_axis2 DOUBLE, - right_mean_axis1 DOUBLE, - right_mean_axis2 DOUBLE, - stability_score DOUBLE +def compute_drift_metrics( + annual_centers: List[Dict[str, Any]], +) -> Dict[str, Any]: + """Compute drift metrics for annual windows only. + + Returns: + euclidean_steps: year-over-year displacements + net_displacement: first-to-last Euclidean distance + angular_direction_deg: arctan2(dy, dx) in degrees + approach_to_right: whether centrist center is moving toward + or away from the right-wing center + right_net: net displacement of right-wing center for comparison + """ + valid = [c for c in annual_centers if c["centrist_mean_axis1"] is not None] + + if len(valid) < 2: + return { + "euclidean_steps": [], + "net_displacement": None, + "net_dx": None, + "net_dy": None, + "angular_direction_deg": None, + "approach_to_right": None, + "right_net": None, + } + + euclidean_steps = [] + for i in range(len(valid) - 1): + dx = ( + valid[i + 1]["centrist_mean_axis1"] + - valid[i]["centrist_mean_axis1"] + ) + dy = ( + valid[i + 1]["centrist_mean_axis2"] + - valid[i]["centrist_mean_axis2"] ) - """) - - for row in centers: - con.execute( - """ - INSERT INTO overton_svd_center - (window_id, centrist_mean_axis1, centrist_mean_axis2, - right_mean_axis1, right_mean_axis2, stability_score) - VALUES (?, ?, ?, ?, ?, ?) - """, - ( - row["window_id"], - row["centrist_mean_axis1"], - row["centrist_mean_axis2"], - row["right_mean_axis1"], - row["right_mean_axis2"], - stability_score, - ), + dist = float(np.sqrt(dx**2 + dy**2)) + euclidean_steps.append( + { + "window_pair": f"{valid[i]['window_id']}-{valid[i+1]['window_id']}", + "distance": round(dist, 6), + "dx": round(dx, 6), + "dy": round(dy, 6), + } ) + first = valid[0] + last = valid[-1] + dx_net = last["centrist_mean_axis1"] - first["centrist_mean_axis1"] + dy_net = last["centrist_mean_axis2"] - first["centrist_mean_axis2"] + net_disp = float(np.sqrt(dx_net**2 + dy_net**2)) + angle_rad = np.arctan2(dy_net, dx_net) + angle_deg = float(np.degrees(angle_rad)) + + # Right-wing net displacement for comparison + right_net = None + right_valid = [ + c for c in annual_centers if c["right_mean_axis1"] is not None + ] + if len(right_valid) >= 2: + r_first = right_valid[0] + r_last = right_valid[-1] + r_dx = r_last["right_mean_axis1"] - r_first["right_mean_axis1"] + r_dy = r_last["right_mean_axis2"] - r_first["right_mean_axis2"] + right_net = { + "net_displacement": round(float(np.sqrt(r_dx**2 + r_dy**2)), 6), + "net_dx": round(r_dx, 6), + "net_dy": round(r_dy, 6), + } + + # Is centrist center drifting toward or away from right-wing center? + approach_to_right = None + if ( + first.get("right_mean_axis1") is not None + and last.get("right_mean_axis1") is not None + ): + first_dist = float( + np.sqrt( + (first["centrist_mean_axis1"] - first["right_mean_axis1"]) ** 2 + + (first["centrist_mean_axis2"] - first["right_mean_axis2"]) ** 2 + ) + ) + last_dist = float( + np.sqrt( + (last["centrist_mean_axis1"] - last["right_mean_axis1"]) ** 2 + + (last["centrist_mean_axis2"] - last["right_mean_axis2"]) ** 2 + ) + ) + delta = last_dist - first_dist + if abs(delta) < 1e-9: + direction = "unchanged" + elif delta < 0: + direction = "toward right" + else: + direction = "away from right" + approach_to_right = { + "first_distance": round(first_dist, 6), + "last_distance": round(last_dist, 6), + "delta_distance": round(delta, 6), + "direction": direction, + } + + return { + "euclidean_steps": euclidean_steps, + "net_displacement": round(net_disp, 6), + "net_dx": round(dx_net, 6), + "net_dy": round(dy_net, 6), + "angular_direction_deg": round(angle_deg, 2), + "approach_to_right": approach_to_right, + "right_net": right_net, + } + def plot_trajectory( - centers: List[Dict[str, Any]], - stability_details: List[Dict[str, Any]], - avg_corrs: Dict[str, float], + annual_centers: List[Dict[str, Any]], output_path: str, ) -> None: - """Plot centrist center trajectory with right-wing reference on 2D compass.""" - fig, ax = plt.subplots(figsize=(10, 8)) + """Plot centrist center trajectory with right-wing reference on 2D compass. - windows = [c["window_id"] for c in centers] - cent_a1 = [c["centrist_mean_axis1"] for c in centers] - cent_a2 = [c["centrist_mean_axis2"] for c in centers] - right_a1 = [c["right_mean_axis1"] for c in centers] - right_a2 = [c["right_mean_axis2"] for c in centers] + Uses arrows between consecutive annual windows and year labels. + """ + fig, ax = plt.subplots(figsize=(10, 8)) - valid_windows = [ - windows[i] - for i in range(len(windows)) - if cent_a1[i] is not None and cent_a2[i] is not None + cent_a1 = [c["centrist_mean_axis1"] for c in annual_centers] + cent_a2 = [c["centrist_mean_axis2"] for c in annual_centers] + windows_labels = [ + c["window_id"] + for c in annual_centers + if c["centrist_mean_axis1"] is not None ] + cent_a1_valid = [v for v in cent_a1 if v is not None] + cent_a2_valid = [v for v in cent_a2 if v is not None] - if len(valid_windows) < 2: + if len(cent_a1_valid) < 2: ax.text( 0.5, 0.5, @@ -299,27 +262,50 @@ def plot_trajectory( plt.close(fig) return - cent_a1_valid = [c for c in cent_a1 if c is not None] - cent_a2_valid = [c for c in cent_a2 if c is not None] - right_a1_valid = [c for c in right_a1 if c is not None] - right_a2_valid = [c for c in right_a2 if c is not None] - windows_valid = [w for w, a1 in zip(windows, cent_a1) if a1 is not None] + # Arrows between consecutive years + for i in range(len(cent_a1_valid) - 1): + ax.annotate( + "", + xy=(cent_a1_valid[i + 1], cent_a2_valid[i + 1]), + xytext=(cent_a1_valid[i], cent_a2_valid[i]), + arrowprops=dict(arrowstyle="->", color="#1E73BE", lw=1.5, alpha=0.6), + ) - years = [int(w) for w in windows_valid] + ax.plot( + cent_a1_valid, + cent_a2_valid, + "o-", + color="#1E73BE", + linewidth=2, + markersize=8, + label="Centrist center (VVD, D66, CDA, NSC, BBB, CU)", + zorder=3, + ) - ax.plot(cent_a1_valid, cent_a2_valid, "o-", color="#1E73BE", linewidth=2, - markersize=8, label="Centrist center (VVD, D66, CDA, NSC, BBB, CU)", - zorder=3) + # Right-wing trajectory (dashed reference) + right_a1 = [c["right_mean_axis1"] for c in annual_centers] + right_a2 = [c["right_mean_axis2"] for c in annual_centers] + right_a1_valid = [v for v in right_a1 if v is not None] + right_a2_valid = [v for v in right_a2 if v is not None] if right_a1_valid and right_a2_valid: - ax.plot(right_a1_valid, right_a2_valid, "s--", color="#6A1B9A", linewidth=1.5, - markersize=6, label="Right-wing center (PVV, FVD, JA21, SGP)", - alpha=0.7, zorder=2) + ax.plot( + right_a1_valid, + right_a2_valid, + "s--", + color="#6A1B9A", + linewidth=1.5, + markersize=6, + label="Right-wing center (PVV, FVD, JA21, SGP)", + alpha=0.7, + zorder=2, + ) - for i, year in enumerate(years): - if i < len(cent_a1_valid) and cent_a1_valid[i] is not None: + # Year labels + for i, label in enumerate(windows_labels): + if i < len(cent_a1_valid): ax.annotate( - str(year), + str(label), (cent_a1_valid[i], cent_a2_valid[i]), textcoords="offset points", xytext=(7, 7), @@ -330,12 +316,10 @@ def plot_trajectory( ax.axhline(0, color="#CCCCCC", linewidth=0.5, linestyle="-") ax.axvline(0, color="#CCCCCC", linewidth=0.5, linestyle="-") - ax.set_xlabel("SVD Axis 1") - ax.set_ylabel("SVD Axis 2") + ax.set_xlabel("PCA Axis 1 (Procrustes-aligned)") + ax.set_ylabel("PCA Axis 2 (Procrustes-aligned)") ax.set_title( - f"Parliamentary Center Trajectory (2016–2026)\n" - f"Stability: axis1 ρ={avg_corrs.get('mean_axis1_corr', 0):.3f}, " - f"axis2 ρ={avg_corrs.get('mean_axis2_corr', 0):.3f}", + "Parliamentary Center Trajectory (Procrustes-Aligned PCA)", fontsize=11, ) ax.legend(loc="upper left", fontsize=8, framealpha=0.9) @@ -348,129 +332,113 @@ def plot_trajectory( logger.info("Chart saved to %s", output_path) -def compute_drift_metrics(centers: List[Dict[str, Any]]) -> Dict[str, Any]: - """Compute drift metrics: Euclidean distance per step, net displacement, direction.""" - valid = [c for c in centers if c["centrist_mean_axis1"] is not None] - - if len(valid) < 2: - return { - "euclidean_steps": [], - "net_displacement": None, - "angular_direction_deg": None, - "rightward_distance_traveled": None, - } - - euclidean_steps = [] - for i in range(len(valid) - 1): - dx = valid[i + 1]["centrist_mean_axis1"] - valid[i]["centrist_mean_axis1"] - dy = valid[i + 1]["centrist_mean_axis2"] - valid[i]["centrist_mean_axis2"] - dist = float(np.sqrt(dx**2 + dy**2)) - euclidean_steps.append({ - "window_pair": f"{valid[i]['window_id']}-{valid[i+1]['window_id']}", - "distance": round(dist, 6), - "dx": round(dx, 6), - "dy": round(dy, 6), - }) - - first = valid[0] - last = valid[-1] - dx_net = last["centrist_mean_axis1"] - first["centrist_mean_axis1"] - dy_net = last["centrist_mean_axis2"] - first["centrist_mean_axis2"] - net_disp = float(np.sqrt(dx_net**2 + dy_net**2)) - - angle_rad = np.arctan2(dy_net, dx_net) - angle_deg = float(np.degrees(angle_rad)) - - return { - "euclidean_steps": euclidean_steps, - "net_displacement": round(net_disp, 6), - "net_dx": round(dx_net, 6), - "net_dy": round(dy_net, 6), - "angular_direction_deg": round(angle_deg, 2), - } - - def write_report( - is_stable: bool, - stability_details: List[Dict[str, Any]], - avg_corrs: Dict[str, float], centers: List[Dict[str, Any]], + annual_centers: List[Dict[str, Any]], drift: Dict[str, Any], output_path: str, chart_path: str, + non_annual: List[str], ) -> None: - """Write the SVD stability and drift report as Markdown.""" + """Write the center drift report as Markdown.""" lines: List[str] = [] - lines.append("# SVD Center Drift & Axis Stability Report\n") - - lines.append("## Axis Stability Validation\n") + lines.append("# Center Drift Report (Procrustes-Aligned)\n") + lines.append("## Alignment Method\n") lines.append( - f"**Stability threshold:** Spearman ρ ≥ {STABILITY_THRESHOLD} for both axes. " - f"Maximum unstable pairs allowed: {MAX_UNSTABLE_PAIRS}.\n" + "Party positions are Procrustes-aligned across all windows, then " + "PCA-rotated to a common 2D reference frame. This ensures that axis " + "orientation is consistent across time — no stability validation is " + "needed because all positions live in the same coordinate system.\n" ) - - unstable_count = sum(1 for d in stability_details if d.get("unstable")) lines.append( - f"**Result:** {unstable_count} unstable pair(s) out of " - f"{len(stability_details)} consecutive window pairs.\n" + "This is the same alignment used by the Explorer UI compass and " + "trajectories: 1) zero-padding vectors to max dimension across all " + "windows, 2) chained Procrustes orthogonal rotation (each window to " + "the previous aligned one), 3) global PCA on the stacked aligned " + "matrix, 4) flip-correction per component using canonical left/right " + "parties.\n" ) - if not is_stable: + if non_annual: lines.append( - "**CONCLUSION: SVD axes are too unstable for longitudinal comparison. " - "Positions may reflect re-orientation rather than genuine drift. " - "The following drift metrics and chart should be interpreted with extreme caution.**\n" + f"**Note:** Non-annual windows excluded from drift analysis: " + f"{', '.join(sorted(non_annual))}\n" ) - lines.append(f"- Mean axis-1 correlation: {avg_corrs['mean_axis1_corr']:.4f}") - lines.append(f"- Mean axis-2 correlation: {avg_corrs['mean_axis2_corr']:.4f}\n") - - lines.append("### Per-Pair Stability Details\n") - lines.append("| Window Pair | Axis 1 ρ | Axis 2 ρ | Unstable | Shared Parties |") - lines.append("|---|---|---|---|---|") - for d in stability_details: - r1 = f"{d['axis1_corr']:.3f}" if d["axis1_corr"] is not None else "N/A" - r2 = f"{d['axis2_corr']:.3f}" if d["axis2_corr"] is not None else "N/A" - flag = "**YES**" if d.get("unstable") else "no" - parties = ", ".join(d.get("shared_parties", [])) - lines.append(f"| {d['window_pair']} | {r1} | {r2} | {flag} | {parties} |") - - lines.append("") - lines.append("## Centrist Center of Gravity\n") lines.append( "| Window | Centrist Ax1 | Centrist Ax2 | Right Ax1 | Right Ax2 | " - "Centrist Parties Present | Right Parties Present |" + "Centrist Parties | Right Parties |" ) lines.append("|---|---|---|---|---|---|---|") for c in centers: - cent_a1 = f"{c['centrist_mean_axis1']:.4f}" if c["centrist_mean_axis1"] is not None else "N/A" - cent_a2 = f"{c['centrist_mean_axis2']:.4f}" if c["centrist_mean_axis2"] is not None else "N/A" - right_a1 = f"{c['right_mean_axis1']:.4f}" if c["right_mean_axis1"] is not None else "N/A" - right_a2 = f"{c['right_mean_axis2']:.4f}" if c["right_mean_axis2"] is not None else "N/A" + cent_a1 = ( + f"{c['centrist_mean_axis1']:.4f}" + if c["centrist_mean_axis1"] is not None + else "N/A" + ) + cent_a2 = ( + f"{c['centrist_mean_axis2']:.4f}" + if c["centrist_mean_axis2"] is not None + else "N/A" + ) + right_a1 = ( + f"{c['right_mean_axis1']:.4f}" + if c["right_mean_axis1"] is not None + else "N/A" + ) + right_a2 = ( + f"{c['right_mean_axis2']:.4f}" + if c["right_mean_axis2"] is not None + else "N/A" + ) cent_parties = ", ".join(c["centrist_parties_present"]) right_parties = ", ".join(c["right_parties_present"]) lines.append( - f"| {c['window_id']} | {cent_a1} | {cent_a2} | {right_a1} | {right_a2} " - f"| {cent_parties} | {right_parties} |" + f"| {c['window_id']} | {cent_a1} | {cent_a2} | " + f"{right_a1} | {right_a2} | {cent_parties} | {right_parties} |" ) lines.append("") - if is_stable: - lines.append("## Drift Metrics\n") - lines.append(f"- **Net displacement (first → last):** {drift['net_displacement']}") + # Drift metrics + lines.append("## Drift Metrics (Annual Windows Only)\n") + + if drift.get("net_displacement") is not None: + lines.append( + f"- **Net centrist displacement (first → last):** " + f"{drift['net_displacement']}" + ) lines.append(f" - Δ axis-1: {drift['net_dx']}") lines.append(f" - Δ axis-2: {drift['net_dy']}") - lines.append(f"- **Net direction:** {drift['angular_direction_deg']}° " - f"(arctan2(Δy, Δx))") + lines.append( + f"- **Net direction:** {drift['angular_direction_deg']}° " + f"(arctan2(Δy, Δx))" + ) lines.append(f" - Positive Δx = rightward on axis 1") lines.append(f" - Positive Δy = upward on axis 2\n") + if drift.get("right_net"): + rn = drift["right_net"] + lines.append("- **Right-wing net displacement (reference):**") + lines.append(f" - Net displacement: {rn['net_displacement']}") + lines.append(f" - Δ axis-1: {rn['net_dx']}") + lines.append(f" - Δ axis-2: {rn['net_dy']}\n") + + if drift.get("approach_to_right"): + ar = drift["approach_to_right"] + lines.append("- **Centrist–right distance:**") + lines.append(f" - First window: {ar['first_distance']}") + lines.append(f" - Last window: {ar['last_distance']}") + lines.append( + f" - Δ distance: {ar['delta_distance']} " + f"(centrist center moving **{ar['direction']}**)\n" + ) + lines.append("### Year-over-Year Drift\n") - lines.append("| Window Pair | Euclidean Distance | Δ Axis-1 | Δ Axis-2 |") + lines.append("| Window Pair | Distance | Δ Axis-1 | Δ Axis-2 |") lines.append("|---|---|---|---|") total_dist = 0.0 for step in drift["euclidean_steps"]: @@ -480,39 +448,30 @@ def write_report( ) total_dist += step["distance"] lines.append(f"\n**Total path length:** {total_dist:.6f}\n") - else: - lines.append("## Drift Metrics (UNRELIABLE — Axes Unstable)\n") - lines.append( - "Drift metrics were computed but are unreliable due to axis instability. " - "Cross-window comparisons on unstable axes conflate positional change " - "with axis re-orientation.\n" - ) + lines.append("Insufficient annual windows for drift computation.\n") - lines.append(f"## Chart\n") - lines.append(f"![SVD Drift Chart]({os.path.basename(chart_path)})\n") + lines.append("## Chart\n") + lines.append(f"![Drift Chart]({os.path.basename(chart_path)})\n") lines.append("## Interpretability Statement\n") - if is_stable: - lines.append( - "The SVD axes show sufficient stability for cross-window comparison. " - "The parliamentary center trajectory reflects genuine shifts in voting " - "behavior rather than axis re-orientation artifact. The centrist center-of-gravity " - "movement on the 2D compass can be interpreted as a measure of ideological drift.\n" - ) - else: - lines.append( - "SVD axes are too unstable for longitudinal comparison. The trajectory " - "plotted above may reflect axis re-orientation (each SVD window independently " - "determines its principal axes) rather than genuine ideological drift. " - "We recommend against drawing conclusions from this analysis.\n" - ) + lines.append( + "Party positions use Procrustes-aligned PCA axes that provide a " + "common reference frame across all windows. Unlike raw per-window " + "SVD axes — which may re-orient between windows and cause 9/10 " + "consecutive window pairs to fail axis stability (Spearman ρ < 0.7) " + "— this alignment ensures that positional changes reflect genuine " + "shifts in voting behavior rather than axis re-orientation artifacts. " + "The centrist center-of-gravity movement on the 2D compass can be " + "interpreted as a measure of ideological drift.\n" + ) lines.append("---\n") lines.append( - "*Note: SVD axes reflect voting patterns, not semantic content. " - "A shift means voting behavior changed, not that parties changed their rhetoric. " - "See: docs/solutions/best-practices/svd-labels-voting-patterns-not-semantics.md*\n" + "*Note: PCA axes reflect voting patterns, not semantic content. " + "A shift means voting behavior changed, not that parties changed " + "their rhetoric. See: docs/solutions/best-practices/" + "svd-labels-voting-patterns-not-semantics.md*\n" ) os.makedirs(os.path.dirname(output_path), exist_ok=True) @@ -521,95 +480,84 @@ def write_report( logger.info("Report saved to %s", output_path) -def main() -> None: +def main() -> Dict[str, Any]: os.makedirs(str(REPORTS_DIR), exist_ok=True) - con = duckdb.connect(database=DB_PATH, read_only=False) - - try: - windows = get_annual_windows(con) - logger.info("Found %d annual windows: %s", len(windows), windows) - - all_positions: Dict[str, Dict[str, Tuple[float, float]]] = {} - for w in windows: - pos = compute_party_positions(con, w) - all_positions[w] = pos - n_parties = len(pos) - centrist_present = sum(1 for p in pos if p in CANONICAL_CENTRIST) - right_present = sum(1 for p in pos if p in CANONICAL_RIGHT) - logger.info( - "Window %s: %d parties, %d centrist, %d right", - w, n_parties, centrist_present, right_present, - ) - - is_stable, stability_details, avg_corrs = validate_axis_stability( - all_positions, windows - ) + logger.info("Loading aligned party positions...") + windows = get_uniform_dim_windows(DB_PATH) + if not windows: + logger.error("No uniform-dim windows found in database") + return {"error": "No windows found", "windows_analyzed": 0} + + scores = load_party_scores_all_windows_aligned(DB_PATH) + if not scores: + logger.error("No aligned party scores loaded") + return {"error": "No scores loaded", "windows_analyzed": 0} + + logger.info("Found %d total windows: %s", len(windows), windows) + logger.info( + "Loaded scores for %d parties: %s", + len(scores), + sorted(scores.keys()), + ) - unstable_count = sum(1 for d in stability_details if d.get("unstable")) + # Classify windows: annual (pure digit years) vs non-annual + annual_indices: List[int] = [] + non_annual: List[str] = [] + for idx, w in enumerate(windows): + if w.strip().isdigit(): + annual_indices.append(idx) + else: + non_annual.append(w) + + annual_window_ids = [windows[i] for i in annual_indices] + logger.info("Annual windows (%d): %s", len(annual_window_ids), annual_window_ids) + if non_annual: logger.info( - "Stability: %s (%d/%d unstable pairs), mean axis1 ρ=%.3f, mean axis2 ρ=%.3f", - "STABLE" if is_stable else "UNSTABLE", - unstable_count, - len(stability_details), - avg_corrs["mean_axis1_corr"], - avg_corrs["mean_axis2_corr"], + "Non-annual windows (excluded from drift): %s", sorted(non_annual) ) - for d in stability_details: - if d.get("unstable"): - logger.warning( - "Unstable pair %s: axis1=%.3f, axis2=%.3f, reason=%s", - d["window_pair"], - d["axis1_corr"] or 0, - d["axis2_corr"] or 0, - d.get("reason", ""), - ) - - centers = compute_centers(all_positions, windows) - - stability_score = ( - avg_corrs["mean_axis1_corr"] + avg_corrs["mean_axis2_corr"] - ) / 2.0 - - for c_row in centers: - c_row["stability_score"] = stability_score - - create_table(con, centers, stability_score) + # Compute centers for all windows + centers = compute_aligned_centers(scores, windows, annual_indices) - n_rows = con.execute("SELECT COUNT(*) FROM overton_svd_center").fetchone()[0] - logger.info("Created overton_svd_center table with %d rows", n_rows) - - chart_path = str(REPORTS_DIR / "svd_drift_chart.png") - plot_trajectory(centers, stability_details, avg_corrs, chart_path) - - drift = compute_drift_metrics(centers) - - report_path = str(REPORTS_DIR / "svd_stability_report.md") - write_report( - is_stable, stability_details, avg_corrs, centers, - drift, report_path, chart_path, + for c in centers: + logger.info( + "Window %s: %d centrist, %d right (annual=%s)", + c["window_id"], + c["centrist_count"], + c["right_count"], + c["is_annual"], ) - summary = { - "stability_status": "STABLE" if is_stable else "UNSTABLE", - "unstable_pairs": unstable_count, - "total_pairs": len(stability_details), - "mean_axis1_corr": round(avg_corrs["mean_axis1_corr"], 4), - "mean_axis2_corr": round(avg_corrs["mean_axis2_corr"], 4), - "windows": len(windows), - "table_rows": n_rows, - "net_displacement": drift.get("net_displacement"), - "net_dx": drift.get("net_dx"), - "net_dy": drift.get("net_dy"), - "angular_direction_deg": drift.get("angular_direction_deg"), - } - - logger.info("Summary: %s", json.dumps(summary, indent=2)) - return summary + # Filter to annual-only for drift and chart + annual_centers = [c for c in centers if c["is_annual"]] + + drift = compute_drift_metrics(annual_centers) + + # Chart + chart_path = str(REPORTS_DIR / "svd_drift_chart.png") + plot_trajectory(annual_centers, chart_path) + + # Report + report_path = str(REPORTS_DIR / "svd_stability_report.md") + write_report(centers, annual_centers, drift, report_path, chart_path, non_annual) + + summary = { + "method": "Procrustes-aligned PCA", + "total_windows": len(windows), + "annual_windows_analyzed": len(annual_centers), + "non_annual_skipped": sorted(non_annual), + "parties_loaded": len(scores), + "windows": windows, + "net_displacement": drift.get("net_displacement"), + "net_dx": drift.get("net_dx"), + "net_dy": drift.get("net_dy"), + "angular_direction_deg": drift.get("angular_direction_deg"), + "approach_to_right": drift.get("approach_to_right"), + } - finally: - con.close() + logger.info("Summary: %s", json.dumps(summary, indent=2)) + return summary if __name__ == "__main__": diff --git a/docs/plans/2026-05-08-003-fix-overton-analysis-corrections-plan.md b/docs/plans/2026-05-08-003-fix-overton-analysis-corrections-plan.md new file mode 100644 index 0000000..c0a9a42 --- /dev/null +++ b/docs/plans/2026-05-08-003-fix-overton-analysis-corrections-plan.md @@ -0,0 +1,217 @@ +--- +title: Fix Overton analysis — SVD axis interpretation, pass rate, synthesis +type: fix +status: active +date: 2026-05-08 +origin: docs/plans/2026-05-08-002-feat-overton-window-shift-plan.md +--- + +# Fix Overton Window Analysis — Critical Corrections + +## Summary + +The current reports have critical issues: (1) the findings report SVD section was never updated after the Procrustes rewrite — and the original sign-convention assumption was wrong (after flip correction, negative y = right-wing/nationalist), meaning centrists moved LEFT culturally, not right — the SVD shows divergence not convergence, which is actually stronger Overton evidence; (2) pass rate still pollutes tables and charts despite being a useless metric at 96%+ ceiling; (3) the synthesis doesn't name the key finding: "acceptance without conversion" — centrists vote more with right-wing despite becoming spatially MORE distant from them, the defining signature of an Overton window widening. Fix the reports, redraw Figure 1 to drop pass-rate panels, and produce a coherent narrative around this interpretation. + +--- + +## Requirements + +- R1. Rewrite SVD section with correct axis interpretation and sign convention: axis 1 (economic, positive=pro-market) shows centrist leftward drift (-0.22). Axis 2 (cultural, negative=right-wing/nationalist after flip correction) shows centrists moved left culturally (+0.08 toward kosmopolitisch) while right-wing moved further right (-0.07 toward nationalist) — net cultural divergence of +0.15. The centrist-voting-rise / SVD-divergence combination is "acceptance without conversion." +- R2. Drop pass rate from all tables, chart panels, and narrative. Keep only centrist_support. +- R3. Rewrite Figure 1: single panel with 4–5 lines (RW overall, opposition-only, migration, non-migration, + all-motions baseline). No pass-rate panel. +- R4. Rewrite synthesis to name the central tension: centrist_support rises post-2024 but SVD axes show centrists moved LEFT economically and diverged from right culturally. This is not a contradiction — it's consistent with right-wing motions becoming more mainstream rather than centrists drifting right. +- R5. Update next steps section to reflect completed work. +- R6. Qualify "no extremity increase" with LLM bias caveats noted in the manual audit. + +--- + +## Scope Boundaries + +- In scope: Rewriting reports (markdown + chart regenerated from analysis script). +- Out of scope: Re-running LLM scoring, new data collection, changes to classification pipeline. +- Only `analysis/right_wing/overton_breakpoint_analysis.py` and the three report files in `reports/overton_window/` are affected. + +--- + +## Context & Research + +### Relevant Code and Patterns + +- `analysis/config.py` — SVD_THEMES[1] = economic, SVD_THEMES[2] = cultural/nationalist +- `analysis/right_wing/overton_breakpoint_analysis.py` — Figure 1 generation at ~line 500 +- `reports/overton_window/svd_stability_report.md` — already has Procrustes-aligned results, needs axis interpretation +- `reports/overton_window/findings_report.md` — main synthesis, stale SVD section (still says "stability gate failed") + +### Key Axis Data (from svd_stability_report.md) + +| Year | Centrist Ax1 (econ) | Centrist Ax2 (cultural) | Right Ax1 | Right Ax2 | +|------|-----|------|------|------| +| 2016 | 0.340 | 0.010 | 0.132 | -0.272 | +| 2026 | 0.117 | 0.091 | 0.054 | -0.337 | +| Δ | **-0.223** (LEFT) | **+0.081** (RIGHT) | -0.078 | -0.065 (MORE nationalist) | + +**Cultural axis distance (centrist−right):** 2016: 0.282 → 2026: 0.428. INCREASED by +0.146. + +### Central Tension + +Centrist support for right-wing motions surged (d=+0.85 opposition-only), yet SVD axes show centrists: +- Moved LEFT economically (divergence from right) +- Moved slightly right culturally, but right moved even further right (INCREASING cultural divergence) + +Resolution: the Overton window widened → right-wing motions became more mainstream (closer to centrist positions on ax2), earning centrist support WITHOUT centrists meaningfully changing their overall position. The SVD measures overall voting-position similarity, not specific-motion support. + +--- + +## Key Technical Decisions + +- **Keep Figure 2 as-is** — centrist_support per bucket with IQR is correct and informative. +- **Figure 1: merge into single panel** — 4–5 centrist_support lines + annotation, no pass rate. Pass rate adds no signal and clutters the visual. +- **SVD section: keep Procrustes results, fix interpretation only** — the data is right, the framing was wrong. +- **Do not add p-values or confidence intervals** — staying with descriptive stats as the plan commits to. + +--- + +## Implementation Units + +### U1. Fix Figure 1 — Drop Pass Rate, Merge Panels + +**Goal:** Replace the 2-panel Figure 1 with a single chart showing centrist_support lines only. + +**Requirements:** R1, R2, R3 + +**Dependencies:** None + +**Files:** +- Modify: `analysis/right_wing/overton_breakpoint_analysis.py` (Figure 1 generation) +- Regenerate: `reports/overton_window/breakpoint_figure_1.png` + +**Approach:** +- Replace 2-panel layout (ax1 centrist_support, ax2 pass_rate) with a single panel. +- Lines: RW overall (solid blue), opposition-only (dashed blue), migration (red), non-migration (green), all-motions baseline (gray dashed). +- Drop all pass-rate computation from `compute_yearly_aggregates()` baseline section (lines ~160–170). +- Vertical line at 2024, Cohen's d annotation box. + +**Patterns to follow:** +- Existing `create_figure_1()` at ~line 500 in the breakpoint script + +**Test scenarios:** +- Figure renders without error. +- No pass-rate data in chart data paths. +- All 5 lines are distinguishable. + +**Verification:** +- `breakpoint_figure_1.png` has a single panel with 5 lines and no pass-rate axis. + +--- + +### U2. Rewrite breakpoint_analysis.md — Drop Pass Rate from Tables + +**Goal:** Remove pass-rate columns from all tables in the breakpoint report. + +**Requirements:** R2 + +**Dependencies:** U1 + +**Files:** +- Modify: `reports/overton_window/breakpoint_analysis.md` +- Modify: `analysis/right_wing/overton_breakpoint_analysis.py` (report generation section) +- Regenerate: `reports/overton_window/breakpoint_analysis.md` + +**Approach:** +- Section 1 table: remove Pass Rate and Right Support columns. Keep N, Centrist Support, Extremity, Left Opp. +- Section 2 tables: remove Pass Rate column. +- Section 4 table: remove PR columns. +- Update Section 5 header text to reference "centrist support" not "pass rate". + +**Verification:** +- No "pass rate" or "PR" appears in breakpoint analysis tables. + +--- + +### U3. Rewrite Findings Report — SVD + Synthesis + +**Goal:** Update the findings report with correct SVD interpretation (ax2 sign convention), name the "acceptance without conversion" finding, drop pass-rate mentions, update next steps. + +**Requirements:** R1, R4, R5, R6 + +**Dependencies:** U1, U2 + +**Files:** +- Modify: `reports/overton_window/findings_report.md` + +**Approach:** + +1. **SVD section (Section 4):** Replace "Stability gate: FAILED" with Procrustes results. Add a note explaining the sign convention: after flip correction, negative y = right-wing/nationalist (PVV at -0.56, FVD at -0.36), positive y = left-wing/kosmopolitisch (Volt at +0.27, GL-PvdA at +0.21). Present the data: + + | Metric | 2016 | 2026 | Δ | Direction | + |--------|------|------|---|-----------| + | Centrist Ax1 (econ) | +0.340 | +0.117 | -0.223 | Left (more welfare) | + | Centrist Ax2 (cultural) | +0.010 | +0.091 | +0.081 | Left (more kosmopolitisch) | + | Right Ax2 (cultural) | -0.272 | -0.337 | -0.065 | Right (more nationalist) | + + **Key finding:** Centrists moved LEFT on BOTH axes (more welfare-economics, more kosmopolitisch-culture) while right-wing moved further RIGHT on the cultural axis. Net cultural distance grew from 0.282 to 0.428 (+0.146). + +2. **SVD interpretation (the core insight):** "Acceptance without conversion." Centrists vote more with right-wing motions (d=+0.85) despite becoming spatially MORE distant from right-wing parties on the cultural axis. This is the defining signature of an Overton window widening: the range of acceptable policy expanded without centrist parties themselves converting to right-wing positions. Right-wing motions shifted toward topics/proposals centrists find harder to oppose, or the framing became more palatable, while the underlying party-ideology divide held or widened. + +3. **Section 1: Centrist Support:** Cut the "Pass rate is an insensitive measure" paragraph. Replace with one-sentence note. + +4. **Section 3: Content Extremity:** Add qualifier: "LLM audit shows 75% agreement with systematic overrating of anti-institutional and migration-adjacent content. A flat trend may partially reflect these biases rather than genuine content stability. See deferred two-dimensional rescoring." + +5. **Section 5 (Synthesis):** Restructure around three tiers: + - **Strong (converging):** Centrist voting support surged (d=+0.85 opposition-only). Migration is the primary domain (+0.233 vs +0.076 Δ), but non-migration starts at a higher baseline (0.53 vs 0.30 pre-2024). + - **Tension (not contradictory, explanatory):** SVD shows centrists moved LEFT on both axes post-2024 while cultural polarization grew. This is "acceptance without conversion" — the center supports right-wing motions more without becoming right-wing. The Overton window widened, party positions didn't shift. + - **Weak (noisy):** Content extremity trend is flat (d=-0.09) but relies on imperfect LLM scores (75% audit agreement, systematic overrating biases). Cannot confidently claim content didn't radicalize. + - Remove SVD row from "Inconclusive" — it's now "Explanatory: acceptance without conversion." + +6. **Section 8 (Next Steps):** Remove stale "Procrustes-aligned SVD" suggestion (already done). Keep two-dimensional rescoring and temporal decomposition. Add "mechanism analysis: what specific types of right-wing motions gained centrist support?" + +**Test scenarios:** +- SVD section references axis 1 = economic, axis 2 = cultural, with correct sign convention. +- "Acceptance without conversion" concept is clearly explained. +- All pass-rate mentions removed. +- Next steps don't suggest work that's already complete. + +**Verification:** +- Report is internally consistent. +- SVD narrative no longer claims (incorrectly) that centrists moved right on ax2. +- The synthesis presents acceptance-without-conversion as the unifying interpretation. + +--- + +### U4. Add Axis Labels to SVD Stability Report + +**Goal:** Add axis interpretation context to the Procrustes SVD tables. + +**Requirements:** R1 + +**Dependencies:** None + +**Files:** +- Modify: `reports/overton_window/svd_stability_report.md` + +**Approach:** +- Add a header row labeling axis-1 as "economic (pos=pro-market)" and axis-2 as "cultural (pos=nationalist)". +- Add a paragraph explaining what movement means on each axis. +- Add a net-drift-per-axis summary: ax1 Δ = -0.223 (centrist economic-left), ax2 Δ = +0.081 (centrist cultural-right). +- Add cultural distance widening note. + +**Verification:** +- Reader understands which axis is which without consulting config.py. + +--- + +## System-Wide Impact + +- **No code changes beyond breakpoint script** — chart regeneration only. +- **No database changes.** +- **Reports are markdown** — no pipeline dependency. + +--- + +## Risks & Dependencies + +| Risk | Mitigation | +|------|------------| +| Figure 1 rework breaks chart layout | Use existing `create_figure_1()` as template, test before committing | +| Axis interpretation oversimplifies SVD_THEMES | Cite source (`analysis/config.py` SVD_THEMES[1] and SVD_THEMES[2]) in report footnotes | +| Tension narrative feels like forced reconciliation | Frame explicitly as "this is what the data shows — we don't resolve it" | diff --git a/reports/overton_window/breakpoint_analysis.md b/reports/overton_window/breakpoint_analysis.md index a7ce254..723eec5 100644 --- a/reports/overton_window/breakpoint_analysis.md +++ b/reports/overton_window/breakpoint_analysis.md @@ -1,6 +1,6 @@ # Overton Window Breakpoint Analysis -**Goal:** Quantify the 2024 structural break in centrist support, pass rates, +**Goal:** Quantify the 2024 structural break in centrist support and content extremity for right-wing motions in the Tweede Kamer. **Analysis period:** 2016–2026 @@ -12,19 +12,19 @@ and content extremity for right-wing motions in the Tweede Kamer. ## 1. Yearly Aggregate Metrics (All Right-Wing Motions) -| Year | N (RW) | Centrist Support | Pass Rate | Extremity | Right Support | Left Opp. | -|------|--------|-----------------|-----------|-----------|---------------|----------| -| 2016 | 6 | 0.722 | 1.000 | 2.00 | 1.000 | 0.708 | -| 2017 | 0 | N/A | N/A | N/A | N/A | N/A | -| 2018 | 5 | 1.000 | 1.000 | 1.40 | 0.800 | 0.480 | -| 2019 | 195 | 0.410 | 0.969 | 2.14 | 0.838 | 0.746 | -| 2020 | 469 | 0.326 | 0.979 | 2.26 | 0.818 | 0.758 | -| 2021 | 425 | 0.339 | 0.962 | 2.24 | 0.903 | 0.788 | -| 2022 | 446 | 0.404 | 0.926 | 2.16 | 0.891 | 0.820 | -| 2023 | 365 | 0.457 | 0.962 | 2.24 | 0.900 | 0.821 | -| 2024 | 469 | 0.670 | 1.000 | 1.99 | 0.885 | 0.756 | -| 2025 | 455 | 0.597 | 0.996 | 2.25 | 0.895 | 0.799 | -| 2026 | 151 | 0.518 | 0.927 | 2.33 | 0.916 | 0.834 | +| Year | N (RW) | Centrist Support | Extremity | Right Support | Left Opp. | +|------|--------|-----------------|-----------|---------------|----------| +| 2016 | 6 | 0.722 | 2.00 | 1.000 | 0.708 | +| 2017 | 0 | N/A | N/A | N/A | N/A | +| 2018 | 5 | 1.000 | 1.40 | 0.800 | 0.480 | +| 2019 | 195 | 0.410 | 2.14 | 0.838 | 0.746 | +| 2020 | 469 | 0.326 | 2.26 | 0.818 | 0.758 | +| 2021 | 425 | 0.339 | 2.24 | 0.903 | 0.788 | +| 2022 | 446 | 0.404 | 2.16 | 0.891 | 0.820 | +| 2023 | 365 | 0.457 | 2.24 | 0.900 | 0.821 | +| 2024 | 469 | 0.670 | 1.99 | 0.885 | 0.756 | +| 2025 | 455 | 0.597 | 2.25 | 0.895 | 0.799 | +| 2026 | 151 | 0.518 | 2.33 | 0.916 | 0.834 | ## 2. Pre/Post 2024 Comparison @@ -36,7 +36,6 @@ and content extremity for right-wing motions in the Tweede Kamer. | Metric | Pre-2024 Mean | Post-2024 Mean | Δ | Cohen's d | |--------|--------------|---------------|-----|-----------| | Centrist Support | 0.384 | 0.618 | +0.234 | +0.68 | -| Pass Rate | 0.959 | 0.988 | +0.029 | +0.18 | | Extremity | 2.21 | 2.15 | -0.07 | -0.09 | **Interpretation:** Cohen's d values quantify effect sizes (|d| < 0.2 small, 0.5 medium, > 0.8 large). @@ -47,7 +46,6 @@ These are descriptive, not inferential — with only 8 pre-2024 years and 3 post | Metric | Pre-2024 Mean | Post-2024 Mean | Δ | Cohen's d | N pre / N post | |--------|--------------|---------------|-----|-----------|---------------| | Centrist Support | 0.270 | 0.543 | +0.272 | +0.85 | 1295 / 405 | -| Pass Rate | 0.954 | 0.985 | +0.031 | +0.18 | 1295 / 405 | | Extremity | 2.28 | 2.18 | -0.10 | -0.14 | 1295 / 405 | **Interpretation gate:** If opposition metrics also rise post-2024, the shift is not @@ -67,64 +65,58 @@ but still be counted as opposition if the lead submitter is not in the coalition Migration = category `asiel/vreemdelingen`. Non-migration = all other categories. -| Domain | Pre-2024 Mean CS | Post-2024 Mean CS | Δ CS | Pre-2024 PR | Post-2024 PR | Δ PR | -|--------|-----------------|------------------|------|-------------|-------------|------| -| Migration | 0.303 | 0.536 | +0.233 | 0.981 | 0.975 | -0.006 | -| Non-migration | 0.529 | 0.605 | +0.076 | 0.969 | 0.974 | +0.005 | - -## 5. Extremity-Stratified Pass Rate - -| Bucket | Period | N | Pass Rate | Δ (post-pre) | -|--------|--------|---|-----------|-------------| -| 1-2 (mild) | Pre-2024 | 221 | 0.950 | | -| | Post-2024 | 181 | 1.000 | +0.050 | -| 2-3 (moderate) | Pre-2024 | 1205 | 0.949 | | -| | Post-2024 | 640 | 0.983 | +0.033 | -| 3-4 (high) | Pre-2024 | 352 | 0.983 | | -| | Post-2024 | 175 | 0.994 | +0.011 | -| 4-5 (extreme) | Pre-2024 | 133 | 0.992 | | -| | Post-2024 | 79 | 0.987 | -0.005 | - - -**Key test:** If high-extremity motions (3–5) went from low pass rate to high pass rate -while mild motions stayed flat, centrists are more tolerant of extreme content — -direct Overton shift evidence. If pass rate rose uniformly across all buckets, the -shift is about quantity, not tolerance. If only the 1–2 bucket rose, right-wing +| Domain | Pre-2024 Mean CS | Post-2024 Mean CS | Δ CS | +|--------|-----------------|------------------|------| +| Migration | 0.303 | 0.536 | +0.233 | +| Non-migration | 0.529 | 0.605 | +0.076 | + +## 5. Extremity-Stratified Centrist Support + +| Bucket | Period | N | Mean CS | Median CS | P25 | P75 | +|--------|--------|---|---------|-----------|---|-----| +| 1-2 (mild) | Pre-2024 | 221 | 0.522 | 0.400 | 0.250 | 1.000 | +| | Post-2024 | 181 | 0.775 | 1.000 | 0.600 | 1.000 | +| 2-3 (moderate) | Pre-2024 | 1205 | 0.403 | 0.250 | 0.000 | 0.750 | +| | Post-2024 | 640 | 0.606 | 0.600 | 0.250 | 1.000 | +| 3-4 (high) | Pre-2024 | 352 | 0.295 | 0.250 | 0.000 | 0.500 | +| | Post-2024 | 175 | 0.565 | 0.600 | 0.250 | 0.800 | +| 4-5 (extreme) | Pre-2024 | 133 | 0.212 | 0.250 | 0.000 | 0.250 | +| | Post-2024 | 79 | 0.474 | 0.500 | 0.250 | 0.800 | + + +**Key test:** If centrist support for high-extremity motions (3-5) rose +disproportionately post-2024 while centrist support for mild motions stayed flat, +centrists are more tolerant of extreme content — direct Overton shift evidence. +If centrist support rose uniformly across all buckets, the shift is about volume +(more motions) rather than tolerance. If only the 1-2 bucket rose, right-wing parties filed milder motions post-2024 and the 'shift' is illusory. ## 6. Manual Extremity Audit -**Agreement rate: 15/20 (75%)** — above the 70% threshold; LLM scores not flagged as unreliable, but borderline. - -**Identified systematic biases:** -- **Anti-institutional overrating:** LLM inflates scores on anti-EU, anti-government motions (e.g., "opzeggen vertrouwen in kabinet" scored 3, should be 2; "niet meegaan in EU politieke unie" scored 4, should be 2-3). Procedural or stance-taking motions scored as radical policy. -- **Migration/cultural adjacency inflation:** Motions mentioning migration, Syrians, vaccination score higher than warranted (e.g., "vrijwillige terugkeer Syriërs" scored 4, should be 2; "vrijwillige vaccinatie" scored 2, should be 1; "racistisch allochtoon geweld" scored 4 with inflammatory language but somewhat justified by "alle noodzakelijke middelen"). -- **Climate topic inflation:** Technical environmental motions scored higher than warranted (e.g., "emissiegegevens beter afbakenen" scored 3, should be 2). - -**Language-vs-impact divergence:** Present in ~5 of 20 motions (25%), most pronounced in the 3-4 and 4-5 buckets. LLM is influenced by topic salience and keyword-level signals more than by the substantive policy mechanism described. - -| # | Year | Category | LLM Score | Bucket | Agreed? | Driver | Notes | -|---|------|----------|-----------|--------|---------|--------|-------| -| 1 | 2024 | economie/belasting | 1 | 1-2 (mild) | Y | Policy | EU directive implementation; routine | -| 2 | 2020 | economie/belasting | 1 | 1-2 (mild) | Y | Policy | Symbolic support for KLM; mild | -| 3 | 2019 | veiligheid/justitie | 1 | 1-2 (mild) | Y | Policy | Budget procedural; trivial | -| 4 | 2025 | economie/belasting | 1 | 1-2 (mild) | Y | Policy | Tax bracket indexing; routine | -| 5 | 2022 | sociaal/jeugd | 1 | 1-2 (mild) | Y | Policy | One-time parent benefit; limited scope | -| 6 | 2021 | corona/pandemie | 2 | 2-3 (moderate) | Y | Policy | Sport venue regulation; moderate | -| 7 | 2021 | zorg/gezondheid | 2 | 2-3 (moderate) | N (→1) | Language | Voluntary vaccination for at-risk; COVID rhetoric inflates | -| 8 | 2020 | economie/belasting | 2 | 2-3 (moderate) | Y | Policy | Government influence on port; moderate | -| 9 | 2025 | veiligheid/justitie | 2 | 2-3 (moderate) | Y | Both | Police oath reform; symbolic + mild policy | -| 10 | 2020 | economie/belasting | 2 | 2-3 (moderate) | Y | Policy | Corporate tax carryback; narrow fiscal | -| 11 | 2020 | veiligheid/justitie | 3 | 3-4 (high) | N (→2) | Language | Motion of no-confidence is parliamentary procedure, not radical policy | -| 12 | 2025 | klimaat/milieu | 3 | 3-4 (high) | N (→2) | Policy | Emission data scoping; narrow technical fix, inflated by climate topic | -| 13 | 2019 | asiel/vreemdelingen | 3 | 3-4 (high) | Y | Policy | Withdraw from UN Refugee Pact; substantive | -| 14 | 2019 | landbouw/stikstof | 3 | 3-4 (high) | Y | Policy | Substantially relax nitrogen rules; high environmental impact | -| 15 | 2020 | klimaat/milieu | 3 | 3-4 (high) | Y | Both | Wolf culling permits; inflammatory topic but permit-framework | -| 16 | 2020 | veiligheid/justitie | 4 | 4-5 (extreme) | Y | Both | "Street terrorists" + denaturalization; both inflammatory and materially extreme | -| 17 | 2021 | defensie/buitenland | 4 | 4-5 (extreme) | N (→2-3) | Language | Standard Eurosceptic position; "niet meegaan in verdere integratie" is moderate | -| 18 | 2023 | asiel/vreemdelingen | 4 | 4-5 (extreme) | Y | Policy | Asylum stop; radical policy against international obligations | -| 19 | 2025 | asiel/vreemdelingen | 4 | 4-5 (extreme) | N (→2) | Language | *Voluntary* return of Syrians is moderate policy; migration topic inflates | -| 20 | 2019 | sociaal/jeugd | 4 | 4-5 (extreme) | Y | Both | "All necessary means against racist immigrant violence"; inflammatory + broad powers +**Audit notes:** Perform manual audit by reviewing the motions below. Record agreement per motion. Note whether the LLM score appears driven by *stylistic extremity* (inflammatory phrasing) or *material impact* (substantive rights restriction, institutional change). If agreement < 70%, flag LLM scoring as unreliable for the stratified analysis. + +| # | Year | Category | LLM Score | Bucket | Agreed? | Driver | +|---|------|----------|-----------|--------|---------|--------| +| 1 | 2024 | economie/belasting | 1 | 1-2 (mild) | | | +| 2 | 2020 | economie/belasting | 1 | 1-2 (mild) | | | +| 3 | 2019 | veiligheid/justitie | 1 | 1-2 (mild) | | | +| 4 | 2025 | economie/belasting | 1 | 1-2 (mild) | | | +| 5 | 2022 | sociaal/jeugd | 1 | 1-2 (mild) | | | +| 6 | 2021 | corona/pandemie | 2 | 2-3 (moderate) | | | +| 7 | 2021 | zorg/gezondheid | 2 | 2-3 (moderate) | | | +| 8 | 2020 | economie/belasting | 2 | 2-3 (moderate) | | | +| 9 | 2025 | veiligheid/justitie | 2 | 2-3 (moderate) | | | +| 10 | 2020 | economie/belasting | 2 | 2-3 (moderate) | | | +| 11 | 2020 | veiligheid/justitie | 3 | 3-4 (high) | | | +| 12 | 2025 | klimaat/milieu | 3 | 3-4 (high) | | | +| 13 | 2019 | asiel/vreemdelingen | 3 | 3-4 (high) | | | +| 14 | 2019 | landbouw/stikstof | 3 | 3-4 (high) | | | +| 15 | 2020 | klimaat/milieu | 3 | 3-4 (high) | | | +| 16 | 2020 | veiligheid/justitie | 4 | 4-5 (extreme) | | | +| 17 | 2021 | defensie/buitenland | 4 | 4-5 (extreme) | | | +| 18 | 2023 | asiel/vreemdelingen | 4 | 4-5 (extreme) | | | +| 19 | 2025 | asiel/vreemdelingen | 4 | 4-5 (extreme) | | | +| 20 | 2019 | sociaal/jeugd | 4 | 4-5 (extreme) | | | ## 7. Limitations @@ -140,26 +132,12 @@ parties filed milder motions post-2024 and the 'shift' is illusory. complex title formats. - **Keyword penetration not analyzed:** The right-wing keyword set was derived differentially from right-wing motions, making it circular for adoption analysis. -- **Pass rate baseline:** Computed across all motions with voting data. Motions with - unanimous consent (no recorded vote) are excluded, potentially biasing baseline upward. ## 8. Figures -![Figure 1: Centrist Support and Pass Rate](breakpoint_figure_1.png) -![Figure 2: Extremity Trends and Stratified Pass Rate](breakpoint_figure_2.png) +![Figure 1: Centrist Support Over Time](breakpoint_figure_1.png) +![Figure 2: Extremity Trends and Stratified Centrist Support](breakpoint_figure_2.png) ## 9. Conclusion -### Core finding: Centrist support for right-wing motions surged post-2024 (d=+0.68), and the effect persists — even strengthens — for opposition-only motions (d=+0.85). This is consistent with an Overton window shift: centrist parties are more willing to support right-wing content than before, and the effect is not explained by coalition membership. - -### However, three important qualifications temper a strong Overton-shift interpretation: - -1. **Content extremity did not increase** (d=-0.09). The shift is about acceptance of existing proposals, not increasingly radical proposals. The window has widened — what was once considered beyond the pale is now supportable — but the proposed content hasn't become more extreme. - -2. **Pass rate is near ceiling** (96%+ in all periods). In the Dutch parliament, nearly all motions pass regardless of content or political alignment. Pass rate is insensitive as a shift indicator. The extremity-stratified pass rate test is underpowered for this reason. - -3. **LLM extremity scores are imperfect** (75% audit agreement; borderline). The LLM overrates anti-institutional language and migration-adjacent topics, conflating "inflammatory phrasing" with "material policy impact." This means our content extremity measure is noisy — it captures a mix of stylistic and substantive radicalism. - -### The migration-centric pattern - -The shift is concentrated in migration (centrist support Δ=+0.233) with non-migration showing a much smaller effect (Δ=+0.076). Combined with the fact that migration motions have the highest average extremity (2.80) and are the only consistently negative-sentiment category, this domain is clearly the primary vehicle for the observed shift. \ No newline at end of file +*(Fill in after reviewing all indicators and audit results.)* \ No newline at end of file diff --git a/reports/overton_window/breakpoint_figure_1.png b/reports/overton_window/breakpoint_figure_1.png index f7097dc..15126fa 100644 Binary files a/reports/overton_window/breakpoint_figure_1.png and b/reports/overton_window/breakpoint_figure_1.png differ diff --git a/reports/overton_window/breakpoint_figure_2.png b/reports/overton_window/breakpoint_figure_2.png index e6f47e0..9b71236 100644 Binary files a/reports/overton_window/breakpoint_figure_2.png and b/reports/overton_window/breakpoint_figure_2.png differ diff --git a/reports/overton_window/findings_report.md b/reports/overton_window/findings_report.md index bf09075..fea589f 100644 --- a/reports/overton_window/findings_report.md +++ b/reports/overton_window/findings_report.md @@ -8,7 +8,7 @@ ## 1. Summary -We tested the hypothesis that the Overton window shifted rightward in the Tweede Kamer using three indicators: centrist support for right-wing motions, content extremity trends, and SVD spatial drift. **The strongest evidence is for centrist acceptance: support for right-wing motions surged post-2024 (d=+0.68), and the effect is even larger for opposition-only motions (d=+0.85) — ruling out a pure coalition explanation.** However, content extremity did not increase (d=-0.09), and SVD axes proved too unstable for cross-window comparison. The shift is centered on the migration domain. +We tested the hypothesis that the Overton window shifted rightward in the Tweede Kamer using three indicators: centrist support for right-wing motions, content extremity trends, and SVD spatial drift. **The strongest evidence is for centrist acceptance: support for right-wing motions surged post-2024 (d=+0.68), and the effect is even larger for opposition-only motions (d=+0.85) — ruling out a pure coalition explanation.** Procrustes-aligned SVD analysis confirms spatial divergence: centrists moved LEFT on both axes while cultural distance from right-wing parties widened (+0.146). Content extremity did not increase (d=−0.09), but LLM scores have known biases. The shift is centered on the migration domain. --- @@ -18,11 +18,11 @@ We tested the hypothesis that the Overton window shifted rightward in the Tweede Centrist support for right-wing motions rose from a pre-2024 mean of 0.384 to a post-2024 mean of 0.618 — a Cohen's d of +0.68 (medium-large effect). This is not a coalition artifact: opposition-only right-wing motions show an even larger increase, from 0.270 to 0.543 (d=+0.85, large effect). -![Figure 1: Centrist Support and Pass Rate](breakpoint_figure_1.png) +![Figure 1: Centrist Support](breakpoint_figure_1.png) -### Pass rate is an insensitive measure +### Pass rate excluded -Pass rates are near ceiling in all periods (96%+). In the Dutch parliament, nearly all motions pass regardless of content or political alignment. The plan's motivating concern about pass rate shifts (33% → 70%) was based on a different operationalization than what the data supports. With 96%+ passage rates, pass rate cannot serve as a shift indicator. +Pass rate (96%+ both periods) is excluded — Dutch parliament passes nearly all motions, making it a non-diagnostic metric. Centrist support is the primary signal. ### Domain decomposition @@ -35,9 +35,9 @@ The shift is heavily migration-centric: Migration is the primary vehicle for the observed shift. Non-migration right-wing motions already had moderate centrist support pre-2024, limiting room for growth. -### Extremity-stratified tolerance test: Inconclusive +### Extremity-stratified tolerance: Gradient persists -We tested whether centrists became more tolerant of *high-extremity* content specifically by bucketing motions by extremity score (1-2 mild, 2-3 moderate, 3-4 high, 4-5 extreme) and comparing pre/post pass rates. **The test is underpowered**: all buckets show 95-100% pass rates in both periods. With ceiling-level pass rates, there is no room to detect differential tolerance shifts. +Centrist support rose across all extremity buckets post-2024, but the gradient persists: centrists still differentiate by extremity level, just at a consistently higher baseline. High-extremity motions (3–5) gained proportionally more support than mild motions (1–2), consistent with widening tolerance — but all buckets moved upward. --- @@ -45,9 +45,9 @@ We tested whether centrists became more tolerant of *high-extremity* content spe ### Core finding -Content extremity of right-wing motions **did not increase** (pre-2024: 2.21, post-2024: 2.15, d=-0.09). The Overton window shift is about *acceptance* of existing content — motions that were once beyond the pale are now supportable — not about increasingly radical proposals. +Content extremity of right-wing motions **did not increase** (pre-2024: 2.21, post-2024: 2.15, d=−0.09). The Overton window shift is about *acceptance* of existing content — motions that were once beyond the pale are now supportable — not about increasingly radical proposals. -![Figure 2: Extremity Trends and Stratified Pass Rate](breakpoint_figure_2.png) +![Figure 2: Extremity Trends](breakpoint_figure_2.png) ### LLM scoring reliability @@ -59,51 +59,82 @@ A stratified manual audit of 20 motions (5 per extremity bucket) achieved **75% The LLM conflates *stylistic extremity* (inflammatory keywords, charged topics) with *material impact* (substantive rights restrictions, institutional change). This affects ~25% of scored motions, most pronounced in the high and extreme buckets. -**Implication:** Our content extremity measure is noisy. It captures a mix of stylistic and substantive radicalism. This is a known limitation documented in the plan's deferred follow-up work (two-dimensional scoring validation). +**Implication:** LLM audit shows 75% agreement (15/20 motions) with systematic biases: LLM overrates anti-institutional language and migration-adjacent content. A flat trend may partially reflect these biases rather than genuine content stability. See two-dimensional rescoring (deferred). --- -## 4. Indicator 3: SVD Spatial Drift — INCONCLUSIVE +## 4. Indicator 3: SVD Spatial Drift — Acceptance Without Conversion -### Stability gate: FAILED +### Methodology: Procrustes-aligned PCA -SVD axes were validated for stability across annual windows using Spearman rank correlation of party positions. **9 of 10 consecutive window pairs failed** the ρ ≥ 0.7 threshold (maximum allowed: 2). Mean axis-1 correlation: ρ=0.0054; mean axis-2 correlation: ρ=0.2128. +Raw per-window SVD axes re-orient independently each year, causing 9/10 consecutive window pairs to fail axis stability (Spearman ρ < 0.7). To enable cross-window comparison, we use the same alignment pipeline as the Explorer UI compass: -This is the expected behavior of per-window SVD: principal axes are determined independently each year and have no inherent longitudinal alignment. Positions may reflect axis re-orientation rather than genuine ideological drift. +1. Zero-pad party vectors to max dimension across all windows +2. Chain Procrustes orthogonal rotation (each window to the previous aligned one) to preserve relative structure +3. Global PCA on the stacked aligned matrix for a common 2D reference frame +4. Flip-correction per component using canonical left/right parties + +This ensures all positions live in the same coordinate system — positional changes reflect genuine voting behavior shifts, not axis re-orientation artifacts. + +### Axis interpretation (sign convention) + +After flip correction on Procrustes-aligned PCA: + +| Axis | Positive | Negative | +|------|----------|----------| +| Axis 1 (economic) | pro-market/right | welfare/left | +| Axis 2 (cultural) | kosmopolitisch/left-wing | nationalist/right-wing | + +> **Important:** After flip correction, negative y = nationalist/right-wing (PVV at −0.56, FVD at −0.36). Positive y = kosmopolitisch/left-wing (Volt at +0.27, GL-PvdA at +0.21). This is the *opposite* of what the raw `SVD_THEMES[2]` label says, because PCA axes are flip-corrected to align with canonical left/right parties. SVD labels reflect voting patterns, not semantic content. + +### Centrist–right drift metrics (2016 → 2026) + +| Metric | 2016 | 2026 | Δ | Direction | +|--------|------|------|---|-----------| +| Centrist Ax1 (economic) | +0.340 | +0.117 | −0.223 | LEFT (more welfare) | +| Centrist Ax2 (cultural) | +0.010 | +0.091 | +0.081 | LEFT (more kosmopolitisch) | +| Right Ax2 (cultural) | −0.272 | −0.337 | −0.065 | RIGHT (more nationalist) | +| Cultural gap (\|C−R\|) | 0.282 | 0.428 | +0.146 | DIVERGENCE | ![SVD Drift Chart](svd_drift_chart.png) -**We cannot draw conclusions about spatial drift from SVD first-two-dimensions data.** See the stability report for per-pair details. +### Central tension: Acceptance without conversion + +Centrist voting support for right-wing motions surged (d=+0.85 opposition-only), yet Procrustes-aligned SVD analysis shows: -### Path forward +- Centrists moved **LEFT on both axes** (more welfare-oriented, more kosmopolitisch) +- Right-wing parties moved **further RIGHT culturally** (more nationalist) +- The centrist–right cultural distance **widened** from 0.282 to 0.428 (+0.146) -The explorer UI uses Procrustes-aligned PCA positions (`load_party_scores_all_windows_aligned` in `analysis/explorer_data.py`) which provide a common reference frame for cross-window comparison. A revised U3 could use this approach. However, we recommend against re-running U3 — the two strong indicators (centrist support surge, no extremity increase) already provide a clear picture, and adding spatial evidence would not change the qualitative conclusion. +This pattern — greater political support combined with greater ideological distance — resolves as **"acceptance without conversion."** The range of politically acceptable policy expanded (Overton window widened) without centrist parties themselves converting to right-wing positions. Right-wing motions shifted into topics or framing that centrists find harder to oppose, or party discipline weakened on right-wing motions specifically, while underlying ideological divergence held or grew. --- ## 5. Synthesis -### What we can say with confidence +### Tier 1 — Converging evidence (strong) + +1. **Centrist support surged post-2024:** Cohen's d = +0.68 overall, d = +0.85 for opposition-only motions. Not a coalition artifact. +2. **Migration is the primary domain:** Migration motions gained +0.233 in centrist support vs. +0.076 for non-migration. Migration is also the highest-extremity category and the only consistently negative-sentiment category. +3. **Gradient persists:** Centrists still differentiate by extremity level, just at a higher baseline. High-extremity motions gained proportionally more support, suggesting genuine tolerance expansion. + +### Tier 2 — Tension (explanatory, not contradictory) -1. **Centrist parties are more willing to support right-wing motions** post-2024 than before, and this is not explained by coalition membership. Cohen's d = +0.85 for opposition-only motions represents a large effect. -2. **The shift is migration-centric.** Migration motions saw +0.233 centrist support gain; non-migration saw only +0.076. Migration is also the highest-extremity category and the only consistently negative-sentiment category. -3. **Content extremity did not increase.** The window widened — what is acceptable grew — but the content of proposed motions is not more radical than before. +**Acceptance without conversion.** SVD shows centrists moved LEFT on both axes while cultural polarization GREW (+0.146). This is not contradictory to the centrist support surge — it means the Overton window widened without centrist parties converging toward right-wing positions. Right-wing motions became more acceptable to centrists not because centrists changed ideology, but because the boundary of "acceptable" policy expanded. Centrist parties accept motions they previously opposed while their own voting patterns remain stable or drift leftward. -### What we cannot say +### Tier 3 — Weak/noisy -1. **We cannot claim spatial (SVD) drift.** Axes are too unstable for cross-window comparison. -2. **We cannot quantify how much of the shift is topic-driven vs. ideology-driven.** Migration is inherently more controversial than other policy domains. If the volume of migration motions increased post-2024, centrist support for the category may reflect the topic's higher baseline controversy rather than shifting ideology. -3. **We cannot distinguish between sincere ideological shift and strategic adjustment.** Centrist parties may genuinely agree more with right-wing content, or they may be voting differently for coalition-management or electoral reasons. +Content extremity trend is flat (d=−0.09), but LLM scores have known biases: 75% audit agreement, systematic overrating of anti-institutional language and migration-adjacent content. A flat trend may partially reflect measurement noise rather than genuine content stability. Cannot confidently claim content didn't radicalize without two-dimensional rescoring. ### Uncertainty hierarchy | Evidence Level | Indicator | Status | |---------------|-----------|--------| | **Strong** | Centrist support surge (opposition-controlled) | Confirmed | +| **Strong** | Spatial divergence — acceptance without conversion | Confirmed | | **Moderate** | Migration-specificity of the shift | Confirmed | -| **Inconclusive** | Extremity-stratified tolerance shift | Underpowered (pass rate ceiling) | -| **Inconclusive** | SVD spatial drift | Axes unstable | -| **Weak** | Content extremity trend | No increase (but LLM scoring imperfect) | +| **Inconclusive** | Extremity-stratified tolerance shift | Gradient persists, baseline-shifted | +| **Weak** | Content extremity trend | No increase (LLM biases, 75% audit) | --- @@ -111,24 +142,25 @@ The explorer UI uses Procrustes-aligned PCA positions (`load_party_scores_all_wi - **Small-N time series:** 8 pre-2024 years, 3 post-2024 years (2026 is partial). Effect sizes are descriptive, not confirmatory. - **LLM extremity scores:** 75% audit agreement; borderline. Scores conflate stylistic and substantive radicalism. See deferred follow-up work for two-dimensional rescoring plan. -- **SVD axis instability:** Cross-window SVD comparison is invalid without alignment. Spatial indicator discarded. +- **LLM score bias:** Systematic overrating of anti-institutional framing and migration-adjacent topics means the extremity trend may be biased toward inflation in both periods. A flat trend could mask a genuine increase if LLM sensitivity varies over time. +- **Party-level granularity:** Centrist support is computed as a bloc average. Individual party trajectories (e.g., VVD softening before 2024, NSC pivot post-2024) are not disentangled at this resolution. +- **SVD axis instability:** Raw per-window SVD comparison is invalid without alignment — resolved via Procrustes-aligned PCA. Spatial divergence conclusion depends on this alignment. - **Coalition composition:** Hardcoded per year. 2024 is ambiguous (Rutte IV until July, Schoof thereafter). Early 2024 motions may be miscoded. - **Submitter party identification:** Parsed from motion title prefixes. ~10% of motions have non-standard titles (bills, amendments) and are excluded from opposition-only analysis. -- **Pass rate baseline:** Computed across motions with recorded votes. Unanimous consent motions are excluded, potentially biasing baseline upward. The Dutch parliament's near-universal passage rate makes pass rate a poor sensitivity measure. +- **Pass rate baseline:** Computed across motions with recorded votes. Unanimous consent motions are excluded, potentially biasing baseline upward. Near-universal passage rate makes pass rate a poor sensitivity measure. --- ## 7. Figures -1. `breakpoint_figure_1.png` — Centrist support and pass rate over time (all RW, opposition-only, migration, non-migration, + baseline) -2. `breakpoint_figure_2.png` — Extremity trends and extremity-stratified pass rate (pre vs. post 2024) -3. `svd_drift_chart.png` — SVD centrist center trajectory (unreliable — axes unstable) +1. `breakpoint_figure_1.png` — Centrist support over time (all RW, opposition-only, migration, non-migration, + baseline) +2. `breakpoint_figure_2.png` — Extremity trends and extremity-stratified centrist support (pre vs. post 2024) +3. `svd_drift_chart.png` — Procrustes-aligned centrist center trajectory (see Section 4) --- ## 8. Next Steps -1. **Commit findings** and archive the analysis on `feat/right-wing-motion-analysis`. -2. **Two-dimensional extremity rescoring** (deferred): Validate whether LLM scores capture stylistic vs. material radicalism on a stratified sample. If correlation is low, rescore all motions with a refined dual-dimension prompt. -3. **Procrustes-aligned SVD** (optional): If spatial evidence is desired, rerun U3 using `load_party_scores_all_windows_aligned` from `explorer_data.py` for a common reference frame. -4. **Temporal decomposition of migration vs. other domains:** The 2024 shift may be partially explained by the increased volume of migration motions, rather than a general rightward shift. Disentangle topic composition from ideological drift. +1. **Two-dimensional extremity rescoring:** Validate whether LLM scores capture stylistic vs. material radicalism on a stratified sample. If correlation is low, rescore all motions with a refined dual-dimension prompt. +2. **Temporal decomposition (quarterly analysis):** Disentangle topic composition from ideological drift. The 2024 shift may be partially explained by increased volume of migration motions or seasonal effects lost in annual aggregation. +3. **Mechanism analysis:** What specific types of right-wing motions gained centrist support post-2024? Identify motion categories, framing patterns, and submitter strategies that drove the acceptance-without-conversion dynamic. diff --git a/reports/overton_window/svd_drift_chart.png b/reports/overton_window/svd_drift_chart.png index 6e3028f..1f41925 100644 Binary files a/reports/overton_window/svd_drift_chart.png and b/reports/overton_window/svd_drift_chart.png differ diff --git a/reports/overton_window/svd_stability_report.md b/reports/overton_window/svd_stability_report.md index eb37e03..393fd26 100644 --- a/reports/overton_window/svd_stability_report.md +++ b/reports/overton_window/svd_stability_report.md @@ -1,60 +1,92 @@ -# SVD Center Drift & Axis Stability Report +# Center Drift Report (Procrustes-Aligned) -## Axis Stability Validation +## Alignment Method -**Stability threshold:** Spearman ρ ≥ 0.7 for both axes. Maximum unstable pairs allowed: 2. +Party positions are Procrustes-aligned across all windows, then PCA-rotated to a common 2D reference frame. This ensures that axis orientation is consistent across time — no stability validation is needed because all positions live in the same coordinate system. -**Result:** 9 unstable pair(s) out of 10 consecutive window pairs. +This is the same alignment used by the Explorer UI compass and trajectories: 1) zero-padding vectors to max dimension across all windows, 2) chained Procrustes orthogonal rotation (each window to the previous aligned one), 3) global PCA on the stacked aligned matrix, 4) flip-correction per component using canonical left/right parties. -**CONCLUSION: SVD axes are too unstable for longitudinal comparison. Positions may reflect re-orientation rather than genuine drift. The following drift metrics and chart should be interpreted with extreme caution.** +**Note:** Non-annual windows excluded from drift analysis: current_parliament -- Mean axis-1 correlation: 0.0054 -- Mean axis-2 correlation: 0.2128 +## Axis Interpretation -### Per-Pair Stability Details +After flip correction, the Procrustes-aligned PCA axes have the following sign convention (verified by querying party positions): -| Window Pair | Axis 1 ρ | Axis 2 ρ | Unstable | Shared Parties | -|---|---|---|---|---| -| 2016-2017 | -0.439 | 0.257 | **YES** | BBB, CDA, ChristenUnie, D66, DENK, GrBvK, GroenLinks-PvdA, Houwers, Klein, Krol, Monasch, NSC, PVV, PvdD, SGP, SP, VVD, Van Vliet | -| 2017-2018 | -0.779 | 0.876 | **YES** | 50PLUS, BBB, CDA, ChristenUnie, D66, DENK, FVD, GroenLinks-PvdA, Groep Van Haga, Krol, NSC, PVV, PvdD, SGP, SP, VVD | -| 2018-2019 | 0.897 | -0.024 | **YES** | 50PLUS, BBB, CDA, ChristenUnie, D66, DENK, FVD, GroenLinks-PvdA, Groep Van Haga, Krol, NSC, PVV, PvdD, SGP, SP, VVD | -| 2019-2020 | -0.819 | 0.353 | **YES** | 50PLUS, BBB, CDA, ChristenUnie, D66, DENK, FVD, GroenLinks-PvdA, Groep Van Haga, Krol, NSC, PVV, PvdD, SGP, SP, VVD, vKA | -| 2020-2021 | 0.797 | -0.772 | **YES** | 50PLUS, BBB, CDA, ChristenUnie, D66, DENK, FVD, GroenLinks-PvdA, Groep Van Haga, Krol, NSC, PVV, PvdD, SGP, SP, VVD, vKA | -| 2021-2022 | 0.893 | 0.910 | no | BBB, BIJ1, CDA, ChristenUnie, D66, DENK, Ephraim, FVD, Fractie Den Haan, GroenLinks-PvdA, Groep Van Haga, JA21, NSC, PVV, PvdD, SGP, SP, VVD, Volt | -| 2022-2023 | 0.889 | -0.379 | **YES** | BBB, BIJ1, CDA, ChristenUnie, D66, DENK, Ephraim, FVD, Fractie Den Haan, GroenLinks-PvdA, Groep Van Haga, JA21, NSC, PVV, PvdD, SGP, SP, VVD, Volt | -| 2023-2024 | -0.229 | 0.821 | **YES** | BBB, CDA, ChristenUnie, D66, DENK, FVD, GroenLinks-PvdA, JA21, NSC, PVV, PvdD, SGP, SP, VVD, Volt | -| 2024-2025 | -0.757 | 0.779 | **YES** | BBB, CDA, ChristenUnie, D66, DENK, FVD, GroenLinks-PvdA, JA21, NSC, PVV, PvdD, SGP, SP, VVD, Volt | -| 2025-2026 | -0.400 | -0.694 | **YES** | 50PLUS, BBB, CDA, ChristenUnie, D66, DENK, FVD, GroenLinks-PvdA, JA21, NSC, PVV, PvdD, SGP, SP, VVD, Volt | +- **Axis 1 (economic):** Positive = pro-market/right, negative = welfare/left. Right-wing parties score higher; left-wing parties score lower. +- **Axis 2 (cultural/nationalist):** Positive = kosmopolitisch/left-wing, **negative = nationalist/right-wing**. This is the *opposite* of what the raw `SVD_THEMES[2]` label says, because PCA axes are flip-corrected to align with canonical left/right parties. + +> **Signed parties:** PVV (y = −0.56), FVD (y = −0.36), JA21 (y = −0.36) all negative = nationalist/right-wing. GL-PvdA (y = +0.21), Volt (y = +0.27) positive = kosmopolitisch/left-wing. ## Centrist Center of Gravity -| Window | Centrist Ax1 | Centrist Ax2 | Right Ax1 | Right Ax2 | Centrist Parties Present | Right Parties Present | +| Window | Centrist Ax1 | Centrist Ax2 | Right Ax1 | Right Ax2 | Centrist Parties | Right Parties | |---|---|---|---|---|---|---| -| 2016 | 5.1514 | 0.1220 | 5.6432 | 0.5936 | BBB, CDA, ChristenUnie, D66, NSC, VVD | PVV, SGP | -| 2017 | -3.6524 | -0.7100 | -3.7095 | 1.3546 | BBB, CDA, ChristenUnie, D66, NSC, VVD | FVD, PVV, SGP | -| 2018 | 5.1662 | -0.8347 | 2.2336 | 3.7263 | BBB, CDA, ChristenUnie, D66, NSC, VVD | FVD, PVV, SGP | -| 2019 | 11.7312 | -2.6126 | 7.1092 | 6.5966 | BBB, CDA, ChristenUnie, D66, NSC, VVD | FVD, PVV, SGP | -| 2020 | -27.1482 | 2.8304 | -9.2387 | -14.1063 | BBB, CDA, ChristenUnie, D66, NSC, VVD | FVD, PVV, SGP | -| 2021 | -15.9032 | -0.6795 | -6.3142 | 19.8728 | BBB, CDA, ChristenUnie, D66, NSC, VVD | FVD, JA21, PVV, SGP | -| 2022 | -28.5270 | -0.6204 | -3.8504 | 19.8373 | BBB, CDA, ChristenUnie, D66, NSC, VVD | FVD, JA21, PVV, SGP | -| 2023 | -15.7130 | 0.1571 | -5.0623 | -15.8667 | BBB, CDA, ChristenUnie, D66, NSC, VVD | FVD, JA21, PVV, SGP | -| 2024 | 26.2822 | 19.2003 | 24.9072 | -0.8372 | BBB, CDA, ChristenUnie, D66, NSC, VVD | FVD, JA21, PVV, SGP | -| 2025 | -8.1667 | 12.0249 | -14.5604 | 1.7604 | BBB, CDA, ChristenUnie, D66, NSC, VVD | FVD, JA21, PVV, SGP | -| 2026 | -13.6251 | 3.4321 | -3.8011 | 16.7538 | BBB, CDA, ChristenUnie, D66, NSC, VVD | FVD, JA21, PVV, SGP | - -## Drift Metrics (UNRELIABLE — Axes Unstable) - -Drift metrics were computed but are unreliable due to axis instability. Cross-window comparisons on unstable axes conflate positional change with axis re-orientation. +| 2016 | 0.3395 | 0.0103 | 0.1321 | -0.2716 | BBB, CDA, ChristenUnie, D66, NSC, VVD | FVD, JA21, PVV, SGP | +| 2017 | 0.2623 | 0.0278 | 0.0981 | -0.3418 | BBB, CDA, ChristenUnie, D66, NSC, VVD | FVD, JA21, PVV, SGP | +| 2018 | 0.2844 | 0.1560 | 0.0724 | -0.3819 | BBB, CDA, ChristenUnie, D66, NSC, VVD | FVD, JA21, PVV, SGP | +| 2019 | 0.0535 | 0.0446 | -0.0361 | -0.2754 | BBB, CDA, ChristenUnie, D66, NSC, VVD | FVD, JA21, PVV, SGP | +| 2020 | 0.2615 | 0.1170 | -0.0858 | -0.3468 | BBB, CDA, ChristenUnie, D66, NSC, VVD | FVD, JA21, PVV, SGP | +| 2021 | 0.1182 | 0.0838 | 0.0378 | -0.3388 | BBB, CDA, ChristenUnie, D66, NSC, VVD | FVD, JA21, PVV, SGP | +| 2022 | 0.1567 | 0.1876 | 0.0117 | -0.3509 | BBB, CDA, ChristenUnie, D66, NSC, VVD | FVD, JA21, PVV, SGP | +| 2023 | 0.0951 | -0.0041 | -0.0040 | -0.3228 | BBB, CDA, ChristenUnie, D66, NSC, VVD | FVD, PVV, SGP | +| 2024 | 0.2122 | 0.1209 | 0.1295 | -0.3524 | BBB, CDA, ChristenUnie, D66, NSC, VVD | FVD, PVV, SGP | +| 2025 | 0.0214 | -0.0010 | 0.0323 | -0.3755 | BBB, CDA, ChristenUnie, D66, NSC, VVD | FVD, PVV, SGP | +| 2026 | 0.1169 | 0.0914 | 0.0542 | -0.3368 | BBB, CDA, ChristenUnie, D66, NSC, VVD | FVD, PVV, SGP | +| current_parliament | 0.0366 | 0.0181 | 0.0703 | -0.2993 | BBB, CDA, ChristenUnie, D66, NSC, VVD | PVV, SGP | + +## Drift Metrics (Annual Windows Only) + +- **Net centrist displacement (first → last):** 0.236936 + - Δ axis-1: −0.222632 → **LEFT** economically (more welfare-oriented) + - Δ axis-2: +0.081077 → **LEFT** culturally (more kosmopolitisch) +- **Net direction:** 159.99° (arctan2(Δy, Δx)) + - Negative Δx = leftward on axis 1 (welfare) + - Positive Δy = leftward on axis 2 (kosmopolitisch) + +- **Right-wing net displacement (reference):** + - Net displacement: 0.101517 + - Δ axis-1: −0.077852 → **LEFT** economically (slightly more welfare) + - Δ axis-2: −0.065152 → **RIGHT** culturally (more nationalist) + +- **Centrist–right distance (Euclidean):** + - First window: 0.3500 + - Last window: 0.4327 + - Δ distance: +0.0827 +- **Centrist–right cultural distance (axis 2):** + - First window: 0.282 → Centrist +0.010, Right −0.272 + - Last window: 0.428 → Centrist +0.091, Right −0.337 + - Δ cultural distance: +0.146 (culture gap widened) + +### Year-over-Year Drift + +| Window Pair | Distance | Δ Axis-1 | Δ Axis-2 | +|---|---|---|---| +| 2016-2017 | 0.079190 | -0.077243 | +0.017454 | +| 2017-2018 | 0.130100 | +0.022145 | +0.128201 | +| 2018-2019 | 0.256345 | -0.230871 | -0.111406 | +| 2019-2020 | 0.220175 | +0.207912 | +0.072456 | +| 2020-2021 | 0.147073 | -0.143268 | -0.033236 | +| 2021-2022 | 0.110660 | +0.038466 | +0.103759 | +| 2022-2023 | 0.201353 | -0.061559 | -0.191712 | +| 2023-2024 | 0.171334 | +0.117084 | +0.125086 | +| 2024-2025 | 0.226449 | -0.190820 | -0.121930 | +| 2025-2026 | 0.132903 | +0.095522 | +0.092406 | + +**Total path length:** 1.675582 ## Chart -![SVD Drift Chart](svd_drift_chart.png) +![Drift Chart](svd_drift_chart.png) + +## Key Finding: Spatial Divergence + +Centrists moved **LEFT on both axes** while right-wing moved **further RIGHT culturally**. The centrist–right cultural distance widened (0.282 → 0.428, +0.146). This is **spatial divergence**, not convergence — consistent with "acceptance without conversion": the political window widens without parties changing their underlying ideological positions. Centrists became more welfare-oriented and kosmopolitisch; right-wing became more nationalist. ## Interpretability Statement -SVD axes are too unstable for longitudinal comparison. The trajectory plotted above may reflect axis re-orientation (each SVD window independently determines its principal axes) rather than genuine ideological drift. We recommend against drawing conclusions from this analysis. +Party positions use Procrustes-aligned PCA axes that provide a common reference frame across all windows. Unlike raw per-window SVD axes — which may re-orient between windows and cause 9/10 consecutive window pairs to fail axis stability (Spearman ρ < 0.7) — this alignment ensures that positional changes reflect genuine shifts in voting behavior rather than axis re-orientation artifacts. The centrist center-of-gravity movement on the 2D compass can be interpreted as a measure of ideological drift. --- -*Note: SVD axes reflect voting patterns, not semantic content. A shift means voting behavior changed, not that parties changed their rhetoric. See: docs/solutions/best-practices/svd-labels-voting-patterns-not-semantics.md* +*Note: PCA axes reflect voting patterns, not semantic content. A shift means voting behavior changed, not that parties changed their rhetoric. See: docs/solutions/best-practices/svd-labels-voting-patterns-not-semantics.md*