fix(overton): correct SVD axis interpretation, drop pass rate, synthesis rewrite

- SVD axis 2 sign corrected: negative = nationalist (PVV -0.56, FVD -0.36), positive = kosmopolitisch (Volt +0.27). Centrists moved LEFT on both axes while right-wing moved further right culturally (+0.146 gap). 'Acceptance without conversion' named as unifying interpretation.
- U1: Figure 1 merged to single panel, pass rate removed, 5 centrist_support lines
- U2: Pass rate columns dropped from all breakpoint tables, PR narrative cut
- U3: Findings report rewritten: SVD section replaced, synthesis restructured into 3 tiers, extremity LLM bias qualified
- U4: Axis labels and sign convention added to svd_stability_report.md
- Added centrist_support_mp column (MP-weighted, correlates 0.998 with party-level)
main
Sven Geboers 1 month ago
parent 76b499cdc0
commit e478235c84
  1. 89
      analysis/right_wing/migrate_mp_level_metrics.py
  2. 250
      analysis/right_wing/overton_breakpoint_analysis.py
  3. 762
      analysis/right_wing/overton_svd_drift.py
  4. 217
      docs/plans/2026-05-08-003-fix-overton-analysis-corrections-plan.md
  5. 152
      reports/overton_window/breakpoint_analysis.md
  6. BIN
      reports/overton_window/breakpoint_figure_1.png
  7. BIN
      reports/overton_window/breakpoint_figure_2.png
  8. 104
      reports/overton_window/findings_report.md
  9. BIN
      reports/overton_window/svd_drift_chart.png
  10. 110
      reports/overton_window/svd_stability_report.md

@ -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()

@ -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]: def compute_yearly_baseline(con: duckdb.DuckDBPyConnection) -> dict[int, dict]:
"""Baseline: pass rate and centrist support across ALL motions (not just RW).""" """Baseline: 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()
yearly: dict[int, dict] = {} yearly: dict[int, dict] = {}
for year in range(YEAR_MIN, YEAR_MAX + 1): for year in range(YEAR_MIN, YEAR_MAX + 1):
yearly[year] = {"passed": [], "centrist_support": []} yearly[year] = {"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)
centrist_rows = con.execute(""" centrist_rows = con.execute("""
SELECT SELECT
@ -365,7 +344,7 @@ def compute_domain_metrics(
def compute_extremity_stratified( def compute_extremity_stratified(
yearly_raw: dict[int, dict], yearly_raw: dict[int, dict],
) -> dict[str, dict[str, list]]: ) -> 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 = { buckets = {
"1-2 (mild)": [], "1-2 (mild)": [],
"2-3 (moderate)": [], "2-3 (moderate)": [],
@ -382,8 +361,8 @@ def compute_extremity_stratified(
period = "pre-2024" if year < BREAK_YEAR else "post-2024" period = "pre-2024" if year < BREAK_YEAR else "post-2024"
for idx in range(len(d["titles"])): for idx in range(len(d["titles"])):
ext = d["extremity"][idx] ext = d["extremity"][idx]
passed = d["passed"][idx] cs = d["centrist_support"][idx]
if np.isnan(ext) or passed is None: if np.isnan(ext) or cs is None or (isinstance(cs, float) and np.isnan(cs)):
continue continue
if ext < 2: if ext < 2:
b = "1-2 (mild)" b = "1-2 (mild)"
@ -393,7 +372,7 @@ def compute_extremity_stratified(
b = "3-4 (high)" b = "3-4 (high)"
else: else:
b = "4-5 (extreme)" b = "4-5 (extreme)"
pre_post[period][b].append(passed) pre_post[period][b].append(cs)
return pre_post return pre_post
@ -492,69 +471,49 @@ def create_figure_1(
non_mig_sum: dict[int, dict], non_mig_sum: dict[int, dict],
baseline_sum: dict[int, dict], baseline_sum: dict[int, dict],
) -> str: ) -> 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 = sorted(yearly_sum.keys())
years_arr = np.array(years) years_arr = np.array(years)
def _vals(summary, key): def _vals(summary, key):
return np.array([summary[y].get(key, np.nan) for y in years]) 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_rw = "#002366"
colour_opp = "#E53935" colour_opp = "#4A90D9"
colour_mig = "#6A1B9A" colour_mig = "#E53935"
colour_non_mig = "#4CAF50" colour_non_mig = "#4CAF50"
colour_baseline = "#9E9E9E" colour_baseline = "#9E9E9E"
# Panel A: Centrist support ax.plot(years_arr, _vals(yearly_sum, "mean_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) marker="o", color=colour_rw, linewidth=2, label="All right-wing", zorder=5)
ax1.plot(years_arr, _vals(opp_sum, "mean_centrist_support"), ax.plot(years_arr, _vals(opp_sum, "mean_centrist_support"),
marker="s", color=colour_opp, linewidth=1.5, linestyle="--", label="Opposition-only RW", zorder=4) marker="s", color=colour_opp, linewidth=1.5, linestyle="--", label="Opposition-only", zorder=4)
ax1.plot(years_arr, _vals(mig_sum, "mean_centrist_support"), ax.plot(years_arr, _vals(mig_sum, "mean_centrist_support"),
marker="^", color=colour_mig, linewidth=1.5, linestyle=":", label="Migration", zorder=3) marker="^", color=colour_mig, linewidth=1.5, linestyle=":", label="Migration", zorder=3)
ax1.plot(years_arr, _vals(non_mig_sum, "mean_centrist_support"), 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) 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"), 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)") 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) ax.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.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) fontsize=9, color="black", alpha=0.7)
ax1.set_ylabel("Mean Centrist Support") ax.text(0.02, 0.98, "Cohen\u2019s d\nOverall: d=+0.68\nOpposition-only: d=+0.85",
ax1.set_title("Centrist Support for Right-Wing Motions Over Time", fontweight="bold") transform=ax.transAxes, fontsize=9, verticalalignment="top",
ax1.legend(loc="lower right", fontsize=8, ncol=2) bbox=dict(boxstyle="round", facecolor="white", alpha=0.8))
ax1.set_ylim(0, 1.05)
ax1.grid(True, alpha=0.3)
# 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) ax.set_xlabel("Year")
ax2.annotate("2024", xy=(BREAK_YEAR - 0.3, ax2.get_ylim()[1] * 0.95 if ax2.get_ylim()[1] > 0 else 0.95), ax.set_ylabel("Centrist support (fraction of parties)")
fontsize=9, color="black", alpha=0.7) ax.set_title("Centrist Support for Right-Wing Motions Over Time", fontweight="bold")
ax.legend(loc="lower right", fontsize=8, ncol=2)
ax2.set_xlabel("Year") ax.set_ylim(0, 1.05)
ax2.set_ylabel("Pass Rate") ax.grid(True, alpha=0.3)
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)
ax2.set_xticks(years_arr) ax.set_xticks(years_arr)
ax2.set_xticklabels([str(y) for y in years], rotation=45) ax.set_xticklabels([str(y) for y in years], rotation=45)
plt.tight_layout() plt.tight_layout()
path = str(REPORTS_DIR / "breakpoint_figure_1.png") path = str(REPORTS_DIR / "breakpoint_figure_1.png")
@ -571,7 +530,7 @@ def create_figure_2(
non_mig_sum: dict[int, dict], non_mig_sum: dict[int, dict],
ext_stratified: dict[str, dict[str, list]], ext_stratified: dict[str, dict[str, list]],
) -> str: ) -> 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 = sorted(yearly_sum.keys())
years_arr = np.array(years) years_arr = np.array(years)
@ -607,7 +566,7 @@ def create_figure_2(
ax1.set_xticks(years_arr) ax1.set_xticks(years_arr)
ax1.set_xticklabels([str(y) for y in years], rotation=45) 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_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_labels = ["1-2\nmild", "2-3\nmoderate", "3-4\nhigh", "4-5\nextreme"]
bucket_colours = ["#81C784", "#FFB74D", "#E57373", "#BA68C8"] bucket_colours = ["#81C784", "#FFB74D", "#E57373", "#BA68C8"]
@ -615,35 +574,58 @@ def create_figure_2(
x = np.arange(len(bucket_order)) x = np.arange(len(bucket_order))
width = 0.35 width = 0.35
pre_rates = [] pre_means, pre_ns = [], []
pre_ns = [] pre_p25s, pre_p75s = [], []
post_rates = [] post_means, post_ns = [], []
post_ns = [] post_p25s, post_p75s = [], []
for b in bucket_order: for b in bucket_order:
pre_data = ext_stratified["pre-2024"].get(b, []) pre_arr = np.array(ext_stratified["pre-2024"].get(b, []))
post_data = ext_stratified["post-2024"].get(b, []) post_arr = np.array(ext_stratified["post-2024"].get(b, []))
pre_rates.append(np.mean(pre_data) if pre_data else 0) n_pre, n_post = len(pre_arr), len(post_arr)
pre_ns.append(len(pre_data)) pre_means.append(np.mean(pre_arr) if n_pre > 0 else 0)
post_rates.append(np.mean(post_data) if post_data else 0) pre_ns.append(n_pre)
post_ns.append(len(post_data)) 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)
bars_pre = ax2.bar(x - width / 2, pre_rates, width, label="Pre-2024 (2016-2023)", 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) 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) 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, 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") 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, 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") 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_xticks(x)
ax2.set_xticklabels(bucket_labels) ax2.set_xticklabels(bucket_labels)
ax2.set_ylabel("Pass Rate") ax2.set_ylabel("Centrist Support")
ax2.set_title("Extremity-Stratified Pass Rate\nPre vs Post 2024", fontweight="bold") ax2.set_title("Extremity-Stratified Centrist Support\nPre vs Post 2024", fontweight="bold")
ax2.legend(fontsize=8) ax2.legend(fontsize=8)
ax2.set_ylim(0, 1.05) ax2.set_ylim(0, 1.05)
ax2.grid(True, alpha=0.3, axis="y") ax2.grid(True, alpha=0.3, axis="y")
@ -683,15 +665,11 @@ def generate_report(
# Pooled pre/post values for Cohen's d # Pooled pre/post values for Cohen's d
rw_pre_cs = [] rw_pre_cs = []
rw_post_cs = [] rw_post_cs = []
rw_pre_pr = []
rw_post_pr = []
rw_pre_ext = [] rw_pre_ext = []
rw_post_ext = [] rw_post_ext = []
opp_pre_cs = [] opp_pre_cs = []
opp_post_cs = [] opp_post_cs = []
opp_pre_pr = []
opp_post_pr = []
opp_pre_ext = [] opp_pre_ext = []
opp_post_ext = [] opp_post_ext = []
@ -699,7 +677,6 @@ def generate_report(
for idx in range(len(d.get("centrist_support", []))): for idx in range(len(d.get("centrist_support", []))):
cs = d["centrist_support"][idx] cs = d["centrist_support"][idx]
ext = d["extremity"][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 not (isinstance(cs, float) and np.isnan(cs)):
if y < BREAK_YEAR: if y < BREAK_YEAR:
rw_pre_cs.append(cs) rw_pre_cs.append(cs)
@ -710,17 +687,11 @@ def generate_report(
rw_pre_ext.append(ext) rw_pre_ext.append(ext)
else: else:
rw_post_ext.append(ext) 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 y, d in opp_raw.items():
for idx in range(len(d.get("centrist_support", []))): for idx in range(len(d.get("centrist_support", []))):
cs = d["centrist_support"][idx] cs = d["centrist_support"][idx]
ext = d["extremity"][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 not (isinstance(cs, float) and np.isnan(cs)):
if y < BREAK_YEAR: if y < BREAK_YEAR:
opp_pre_cs.append(cs) opp_pre_cs.append(cs)
@ -731,49 +702,54 @@ def generate_report(
opp_pre_ext.append(ext) opp_pre_ext.append(ext)
else: else:
opp_post_ext.append(ext) 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_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_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_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") 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 summary table
yearly_table = "| Year | N (RW) | Centrist Support | Pass Rate | Extremity | Right Support | Left Opp. |\n" yearly_table = "| Year | N (RW) | Centrist Support | Extremity | Right Support | Left Opp. |\n"
yearly_table += "|------|--------|-----------------|-----------|-----------|---------------|----------|\n" yearly_table += "|------|--------|-----------------|-----------|---------------|----------|\n"
for y in years: for y in years:
n = _val(yearly_sum, y, "n") n = _val(yearly_sum, y, "n")
cs = _val(yearly_sum, y, "mean_centrist_support") cs = _val(yearly_sum, y, "mean_centrist_support")
pr = _val(yearly_sum, y, "pass_rate")
ext = _val(yearly_sum, y, "mean_extremity") ext = _val(yearly_sum, y, "mean_extremity")
rs = _val(yearly_sum, y, "mean_right_support") rs = _val(yearly_sum, y, "mean_right_support")
lo = _val(yearly_sum, y, "mean_left_opposition") lo = _val(yearly_sum, y, "mean_left_opposition")
cs_str = f"{cs:.3f}" if not np.isnan(cs) else "N/A" 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" 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" 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" 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)"] 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 = "| Bucket | Period | N | Mean CS | Median CS | P25 | P75 |\n"
ext_table += "|--------|--------|---|-----------|-------------|\n" ext_table += "|--------|--------|---|---------|-----------|---|-----|\n"
for b in bucket_order: for b in bucket_order:
pre_data = ext_stratified["pre-2024"].get(b, []) pre_arr = np.array(ext_stratified["pre-2024"].get(b, []))
post_data = ext_stratified["post-2024"].get(b, []) post_arr = np.array(ext_stratified["post-2024"].get(b, []))
pre_pr = np.mean(pre_data) if pre_data else float("nan") n_pre, n_post = len(pre_arr), len(post_arr)
post_pr = np.mean(post_data) if post_data else float("nan") if n_pre > 0:
delta = post_pr - pre_pr if not np.isnan(pre_pr) and not np.isnan(post_pr) else float("nan") p_mean, p_med = np.mean(pre_arr), np.median(pre_arr)
ext_table += f"| {b} | Pre-2024 | {len(pre_data)} | {pre_pr:.3f} | |\n" p_p25, p_p75 = np.percentile(pre_arr, [25, 75])
ext_table += f"| | Post-2024 | {len(post_data)} | {post_pr:.3f} | {delta:+.3f} |\n" 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
audit_table = "| # | Year | Category | LLM Score | Bucket | Agreed? | Driver |\n" audit_table = "| # | Year | Category | LLM Score | Bucket | Agreed? | Driver |\n"
@ -784,7 +760,7 @@ def generate_report(
lines = [ lines = [
"# Overton Window Breakpoint Analysis", "# 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.", "and content extremity for right-wing motions in the Tweede Kamer.",
"", "",
"**Analysis period:** 2016–2026", "**Analysis period:** 2016–2026",
@ -807,7 +783,6 @@ def generate_report(
f"| Metric | Pre-2024 Mean | Post-2024 Mean | Δ | Cohen's d |", f"| Metric | Pre-2024 Mean | Post-2024 Mean | Δ | Cohen's d |",
f"|--------|--------------|---------------|-----|-----------|", 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"| 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"| 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).", 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"| Metric | Pre-2024 Mean | Post-2024 Mean | Δ | Cohen's d | N pre / N post |",
f"|--------|--------------|---------------|-----|-----------|---------------|", 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"| 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)} |", 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", "**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.", "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)]: 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]) 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]) 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( lines.append(
f"| {domain_name} | {pre_cs:.3f} | {post_cs:.3f} | {post_cs - pre_cs:+.3f} | " 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} |"
) )
lines += [ lines += [
"", "",
"## 5. Extremity-Stratified Pass Rate", "## 5. Extremity-Stratified Centrist Support",
"", "",
ext_table, ext_table,
"", "",
"**Key test:** If high-extremity motions (3–5) went from low pass rate to high pass rate", "**Key test:** If centrist support for high-extremity motions (3-5) rose",
"while mild motions stayed flat, centrists are more tolerant of extreme content —", "disproportionately post-2024 while centrist support for mild motions stayed flat,",
"direct Overton shift evidence. If pass rate rose uniformly across all buckets, the", "centrists are more tolerant of extreme content — direct Overton shift evidence.",
"shift is about quantity, not tolerance. If only the 1–2 bucket rose, right-wing", "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.", "parties filed milder motions post-2024 and the 'shift' is illusory.",
"", "",
"## 6. Manual Extremity Audit", "## 6. Manual Extremity Audit",
@ -883,13 +855,11 @@ def generate_report(
" complex title formats.", " complex title formats.",
"- **Keyword penetration not analyzed:** The right-wing keyword set was derived", "- **Keyword penetration not analyzed:** The right-wing keyword set was derived",
" differentially from right-wing motions, making it circular for adoption analysis.", " 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", "## 8. Figures",
"", "",
f"![Figure 1: Centrist Support and Pass Rate]({Path(fig1_path).name})", f"![Figure 1: Centrist Support Over Time]({Path(fig1_path).name})",
f"![Figure 2: Extremity Trends and Stratified Pass Rate]({Path(fig2_path).name})", f"![Figure 2: Extremity Trends and Stratified Centrist Support]({Path(fig2_path).name})",
"", "",
"## 9. Conclusion", "## 9. Conclusion",
"", "",

@ -1,9 +1,10 @@
#!/usr/bin/env python3 #!/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, Uses Procrustes-aligned, PCA-rotated 2D party positions from
validates axis stability across consecutive windows, then measures rightward load_party_scores_all_windows_aligned() to measure rightward drift
drift of the centrist center of gravity on axis 1 and axis 2. of the centrist center of gravity on a common reference frame.
Axes are aligned across all windows no stability validation needed.
Usage: Usage:
uv run python analysis/right_wing/overton_svd_drift.py uv run python analysis/right_wing/overton_svd_drift.py
@ -15,15 +16,12 @@ import json
import logging import logging
import os import os
import sys import sys
from collections import defaultdict
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List
import duckdb
import matplotlib import matplotlib
import matplotlib.pyplot as plt import matplotlib.pyplot as plt
import numpy as np import numpy as np
from scipy.stats import spearmanr
matplotlib.use("Agg") matplotlib.use("Agg")
@ -32,261 +30,226 @@ if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT))
from analysis.config import CANONICAL_RIGHT, PARTY_COLOURS, _PARTY_NORMALIZE 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") logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger("overton_svd_drift") 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") DB_PATH = str(ROOT / "data" / "motions.db")
REPORTS_DIR = ROOT / "reports" / "overton_window" REPORTS_DIR = ROOT / "reports" / "overton_window"
STABILITY_THRESHOLD = 0.7
MAX_UNSTABLE_PAIRS = 2
def _normalize_party(raw: str) -> str: def _normalize_party(raw: str) -> str:
"""Normalize a raw party name to its canonical abbreviation.""" """Normalize a raw party name to its canonical abbreviation."""
return _PARTY_NORMALIZE.get(raw, raw) return _PARTY_NORMALIZE.get(raw, raw)
def compute_party_positions( def _party_in_set(party: str, canonical_set: frozenset) -> bool:
con: duckdb.DuckDBPyConnection, window_id: str """Check party membership against a canonical set.
) -> 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]: Checks the raw party name and its normalized form so that both
"""Return sorted list of annual window IDs (exclude quarterly and current_parliament).""" 'CU' and 'ChristenUnie' match a set containing either variant.
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() if party in canonical_set:
return [r[0] for r in rows] return True
normalized = _normalize_party(party)
return normalized != party and normalized in canonical_set
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.
Returns (is_stable, stability_details, avg_correlations).
"""
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 def compute_aligned_centers(
scores: Dict[str, List[List[float]]],
def compute_centers(
all_positions: Dict[str, Dict[str, Tuple[float, float]]],
windows: List[str], windows: List[str],
annual_indices: List[int],
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
"""Compute centrist and right-wing centers of gravity per window. """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]] = [] results: List[Dict[str, Any]] = []
for window_id in windows: for idx, window_id in enumerate(windows):
pos = all_positions.get(window_id, {}) 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 = [] for party, window_scores in scores.items():
centrist_a2 = [] if idx >= len(window_scores):
right_a1 = [] continue
right_a2 = [] a1, a2 = window_scores[idx]
for party, (a1, a2) in pos.items(): if _party_in_set(party, CANONICAL_CENTRIST):
if party in CANONICAL_CENTRIST:
centrist_a1.append(a1) centrist_a1.append(a1)
centrist_a2.append(a2) 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_a1.append(a1)
right_a2.append(a2) right_a2.append(a2)
right_present.append(party)
centrist_mean_a1 = float(np.mean(centrist_a1)) if centrist_a1 else None results.append(
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, "window_id": window_id,
"centrist_mean_axis1": centrist_mean_a1, "centrist_mean_axis1": float(np.mean(centrist_a1)) if centrist_a1 else None,
"centrist_mean_axis2": centrist_mean_a2, "centrist_mean_axis2": float(np.mean(centrist_a2)) if centrist_a2 else None,
"right_mean_axis1": right_mean_a1, "right_mean_axis1": float(np.mean(right_a1)) if right_a1 else None,
"right_mean_axis2": right_mean_a2, "right_mean_axis2": float(np.mean(right_a2)) if right_a2 else None,
"centrist_parties_present": sorted( "centrist_parties_present": sorted(centrist_present),
p for p in pos if p in CANONICAL_CENTRIST "right_parties_present": sorted(right_present),
), "centrist_count": len(centrist_present),
"right_parties_present": sorted( "right_count": len(right_present),
p for p in pos if p in CANONICAL_RIGHT "is_annual": idx in annual_indices,
), }
}) )
return results return results
def create_table( def compute_drift_metrics(
con: duckdb.DuckDBPyConnection, annual_centers: List[Dict[str, Any]],
centers: List[Dict[str, Any]], ) -> Dict[str, Any]:
stability_score: float, """Compute drift metrics for annual windows only.
) -> None:
"""Create/replace the overton_svd_center table.""" Returns:
con.execute("DROP TABLE IF EXISTS overton_svd_center") euclidean_steps: year-over-year displacements
con.execute(""" net_displacement: first-to-last Euclidean distance
CREATE TABLE overton_svd_center ( angular_direction_deg: arctan2(dy, dx) in degrees
window_id VARCHAR PRIMARY KEY, approach_to_right: whether centrist center is moving toward
centrist_mean_axis1 DOUBLE, or away from the right-wing center
centrist_mean_axis2 DOUBLE, right_net: net displacement of right-wing center for comparison
right_mean_axis1 DOUBLE,
right_mean_axis2 DOUBLE,
stability_score DOUBLE
)
""")
for row in centers:
con.execute(
""" """
INSERT INTO overton_svd_center valid = [c for c in annual_centers if c["centrist_mean_axis1"] is not None]
(window_id, centrist_mean_axis1, centrist_mean_axis2,
right_mean_axis1, right_mean_axis2, stability_score) if len(valid) < 2:
VALUES (?, ?, ?, ?, ?, ?) return {
""", "euclidean_steps": [],
( "net_displacement": None,
row["window_id"], "net_dx": None,
row["centrist_mean_axis1"], "net_dy": None,
row["centrist_mean_axis2"], "angular_direction_deg": None,
row["right_mean_axis1"], "approach_to_right": None,
row["right_mean_axis2"], "right_net": None,
stability_score, }
),
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))
# 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( def plot_trajectory(
centers: List[Dict[str, Any]], annual_centers: List[Dict[str, Any]],
stability_details: List[Dict[str, Any]],
avg_corrs: Dict[str, float],
output_path: str, output_path: str,
) -> None: ) -> None:
"""Plot centrist center trajectory with right-wing reference on 2D compass.""" """Plot centrist center trajectory with right-wing reference on 2D compass.
fig, ax = plt.subplots(figsize=(10, 8))
windows = [c["window_id"] for c in centers] Uses arrows between consecutive annual windows and year labels.
cent_a1 = [c["centrist_mean_axis1"] for c in centers] """
cent_a2 = [c["centrist_mean_axis2"] for c in centers] fig, ax = plt.subplots(figsize=(10, 8))
right_a1 = [c["right_mean_axis1"] for c in centers]
right_a2 = [c["right_mean_axis2"] for c in centers]
valid_windows = [ cent_a1 = [c["centrist_mean_axis1"] for c in annual_centers]
windows[i] cent_a2 = [c["centrist_mean_axis2"] for c in annual_centers]
for i in range(len(windows)) windows_labels = [
if cent_a1[i] is not None and cent_a2[i] is not None 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( ax.text(
0.5, 0.5,
0.5, 0.5,
@ -299,27 +262,50 @@ def plot_trajectory(
plt.close(fig) plt.close(fig)
return return
cent_a1_valid = [c for c in cent_a1 if c is not None] # Arrows between consecutive years
cent_a2_valid = [c for c in cent_a2 if c is not None] for i in range(len(cent_a1_valid) - 1):
right_a1_valid = [c for c in right_a1 if c is not None] ax.annotate(
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] 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, # Right-wing trajectory (dashed reference)
markersize=8, label="Centrist center (VVD, D66, CDA, NSC, BBB, CU)", right_a1 = [c["right_mean_axis1"] for c in annual_centers]
zorder=3) 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: if right_a1_valid and right_a2_valid:
ax.plot(right_a1_valid, right_a2_valid, "s--", color="#6A1B9A", linewidth=1.5, ax.plot(
markersize=6, label="Right-wing center (PVV, FVD, JA21, SGP)", right_a1_valid,
alpha=0.7, zorder=2) 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): # Year labels
if i < len(cent_a1_valid) and cent_a1_valid[i] is not None: for i, label in enumerate(windows_labels):
if i < len(cent_a1_valid):
ax.annotate( ax.annotate(
str(year), str(label),
(cent_a1_valid[i], cent_a2_valid[i]), (cent_a1_valid[i], cent_a2_valid[i]),
textcoords="offset points", textcoords="offset points",
xytext=(7, 7), xytext=(7, 7),
@ -330,12 +316,10 @@ def plot_trajectory(
ax.axhline(0, color="#CCCCCC", linewidth=0.5, linestyle="-") ax.axhline(0, color="#CCCCCC", linewidth=0.5, linestyle="-")
ax.axvline(0, color="#CCCCCC", linewidth=0.5, linestyle="-") ax.axvline(0, color="#CCCCCC", linewidth=0.5, linestyle="-")
ax.set_xlabel("SVD Axis 1") ax.set_xlabel("PCA Axis 1 (Procrustes-aligned)")
ax.set_ylabel("SVD Axis 2") ax.set_ylabel("PCA Axis 2 (Procrustes-aligned)")
ax.set_title( ax.set_title(
f"Parliamentary Center Trajectory (2016–2026)\n" "Parliamentary Center Trajectory (Procrustes-Aligned PCA)",
f"Stability: axis1 ρ={avg_corrs.get('mean_axis1_corr', 0):.3f}, "
f"axis2 ρ={avg_corrs.get('mean_axis2_corr', 0):.3f}",
fontsize=11, fontsize=11,
) )
ax.legend(loc="upper left", fontsize=8, framealpha=0.9) 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) 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( def write_report(
is_stable: bool,
stability_details: List[Dict[str, Any]],
avg_corrs: Dict[str, float],
centers: List[Dict[str, Any]], centers: List[Dict[str, Any]],
annual_centers: List[Dict[str, Any]],
drift: Dict[str, Any], drift: Dict[str, Any],
output_path: str, output_path: str,
chart_path: str, chart_path: str,
non_annual: List[str],
) -> None: ) -> None:
"""Write the SVD stability and drift report as Markdown.""" """Write the center drift report as Markdown."""
lines: List[str] = [] lines: List[str] = []
lines.append("# SVD Center Drift & Axis Stability Report\n") lines.append("# Center Drift Report (Procrustes-Aligned)\n")
lines.append("## Axis Stability Validation\n")
lines.append("## Alignment Method\n")
lines.append( lines.append(
f"**Stability threshold:** Spearman ρ ≥ {STABILITY_THRESHOLD} for both axes. " "Party positions are Procrustes-aligned across all windows, then "
f"Maximum unstable pairs allowed: {MAX_UNSTABLE_PAIRS}.\n" "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( lines.append(
f"**Result:** {unstable_count} unstable pair(s) out of " "This is the same alignment used by the Explorer UI compass and "
f"{len(stability_details)} consecutive window pairs.\n" "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( lines.append(
"**CONCLUSION: SVD axes are too unstable for longitudinal comparison. " f"**Note:** Non-annual windows excluded from drift analysis: "
"Positions may reflect re-orientation rather than genuine drift. " f"{', '.join(sorted(non_annual))}\n"
"The following drift metrics and chart should be interpreted with extreme caution.**\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("## Centrist Center of Gravity\n")
lines.append( lines.append(
"| Window | Centrist Ax1 | Centrist Ax2 | Right Ax1 | Right Ax2 | " "| Window | Centrist Ax1 | Centrist Ax2 | Right Ax1 | Right Ax2 | "
"Centrist Parties Present | Right Parties Present |" "Centrist Parties | Right Parties |"
) )
lines.append("|---|---|---|---|---|---|---|") lines.append("|---|---|---|---|---|---|---|")
for c in centers: for c in centers:
cent_a1 = f"{c['centrist_mean_axis1']:.4f}" if c["centrist_mean_axis1"] is not None else "N/A" cent_a1 = (
cent_a2 = f"{c['centrist_mean_axis2']:.4f}" if c["centrist_mean_axis2"] is not None else "N/A" f"{c['centrist_mean_axis1']:.4f}"
right_a1 = f"{c['right_mean_axis1']:.4f}" if c["right_mean_axis1"] is not None else "N/A" if c["centrist_mean_axis1"] is not None
right_a2 = f"{c['right_mean_axis2']:.4f}" if c["right_mean_axis2"] is not None else "N/A" 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"]) cent_parties = ", ".join(c["centrist_parties_present"])
right_parties = ", ".join(c["right_parties_present"]) right_parties = ", ".join(c["right_parties_present"])
lines.append( lines.append(
f"| {c['window_id']} | {cent_a1} | {cent_a2} | {right_a1} | {right_a2} " f"| {c['window_id']} | {cent_a1} | {cent_a2} | "
f"| {cent_parties} | {right_parties} |" f"{right_a1} | {right_a2} | {cent_parties} | {right_parties} |"
) )
lines.append("") lines.append("")
if is_stable: # Drift metrics
lines.append("## Drift Metrics\n") lines.append("## Drift Metrics (Annual Windows Only)\n")
lines.append(f"- **Net displacement (first → last):** {drift['net_displacement']}")
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-1: {drift['net_dx']}")
lines.append(f" - Δ axis-2: {drift['net_dy']}") lines.append(f" - Δ axis-2: {drift['net_dy']}")
lines.append(f"- **Net direction:** {drift['angular_direction_deg']}° " lines.append(
f"(arctan2(Δy, Δx))") f"- **Net direction:** {drift['angular_direction_deg']}° "
f"(arctan2(Δy, Δx))"
)
lines.append(f" - Positive Δx = rightward on axis 1") lines.append(f" - Positive Δx = rightward on axis 1")
lines.append(f" - Positive Δy = upward on axis 2\n") 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("### 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("|---|---|---|---|") lines.append("|---|---|---|---|")
total_dist = 0.0 total_dist = 0.0
for step in drift["euclidean_steps"]: for step in drift["euclidean_steps"]:
@ -480,39 +448,30 @@ def write_report(
) )
total_dist += step["distance"] total_dist += step["distance"]
lines.append(f"\n**Total path length:** {total_dist:.6f}\n") lines.append(f"\n**Total path length:** {total_dist:.6f}\n")
else: else:
lines.append("## Drift Metrics (UNRELIABLE — Axes Unstable)\n") lines.append("Insufficient annual windows for drift computation.\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(f"## Chart\n") lines.append("## Chart\n")
lines.append(f"![SVD Drift Chart]({os.path.basename(chart_path)})\n") lines.append(f"![Drift Chart]({os.path.basename(chart_path)})\n")
lines.append("## Interpretability Statement\n") lines.append("## Interpretability Statement\n")
if is_stable:
lines.append( lines.append(
"The SVD axes show sufficient stability for cross-window comparison. " "Party positions use Procrustes-aligned PCA axes that provide a "
"The parliamentary center trajectory reflects genuine shifts in voting " "common reference frame across all windows. Unlike raw per-window "
"behavior rather than axis re-orientation artifact. The centrist center-of-gravity " "SVD axes — which may re-orient between windows and cause 9/10 "
"movement on the 2D compass can be interpreted as a measure of ideological drift.\n" "consecutive window pairs to fail axis stability (Spearman ρ < 0.7) "
) "— this alignment ensures that positional changes reflect genuine "
else: "shifts in voting behavior rather than axis re-orientation artifacts. "
lines.append( "The centrist center-of-gravity movement on the 2D compass can be "
"SVD axes are too unstable for longitudinal comparison. The trajectory " "interpreted as a measure of ideological drift.\n"
"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("---\n") lines.append("---\n")
lines.append( lines.append(
"*Note: SVD axes reflect voting patterns, not semantic content. " "*Note: PCA axes reflect voting patterns, not semantic content. "
"A shift means voting behavior changed, not that parties changed their rhetoric. " "A shift means voting behavior changed, not that parties changed "
"See: docs/solutions/best-practices/svd-labels-voting-patterns-not-semantics.md*\n" "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) os.makedirs(os.path.dirname(output_path), exist_ok=True)
@ -521,96 +480,85 @@ def write_report(
logger.info("Report saved to %s", output_path) logger.info("Report saved to %s", output_path)
def main() -> None: def main() -> Dict[str, Any]:
os.makedirs(str(REPORTS_DIR), exist_ok=True) os.makedirs(str(REPORTS_DIR), exist_ok=True)
con = duckdb.connect(database=DB_PATH, read_only=False) 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}
try: scores = load_party_scores_all_windows_aligned(DB_PATH)
windows = get_annual_windows(con) if not scores:
logger.info("Found %d annual windows: %s", len(windows), windows) logger.error("No aligned party scores loaded")
return {"error": "No scores loaded", "windows_analyzed": 0}
all_positions: Dict[str, Dict[str, Tuple[float, float]]] = {} logger.info("Found %d total windows: %s", len(windows), windows)
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( logger.info(
"Window %s: %d parties, %d centrist, %d right", "Loaded scores for %d parties: %s",
w, n_parties, centrist_present, right_present, len(scores),
sorted(scores.keys()),
) )
is_stable, stability_details, avg_corrs = validate_axis_stability( # Classify windows: annual (pure digit years) vs non-annual
all_positions, windows 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)
unstable_count = sum(1 for d in stability_details if d.get("unstable")) 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( logger.info(
"Stability: %s (%d/%d unstable pairs), mean axis1 ρ=%.3f, mean axis2 ρ=%.3f", "Non-annual windows (excluded from drift): %s", sorted(non_annual)
"STABLE" if is_stable else "UNSTABLE",
unstable_count,
len(stability_details),
avg_corrs["mean_axis1_corr"],
avg_corrs["mean_axis2_corr"],
) )
for d in stability_details: # Compute centers for all windows
if d.get("unstable"): centers = compute_aligned_centers(scores, windows, annual_indices)
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: for c in centers:
c_row["stability_score"] = stability_score logger.info(
"Window %s: %d centrist, %d right (annual=%s)",
c["window_id"],
c["centrist_count"],
c["right_count"],
c["is_annual"],
)
create_table(con, centers, stability_score) # Filter to annual-only for drift and chart
annual_centers = [c for c in centers if c["is_annual"]]
n_rows = con.execute("SELECT COUNT(*) FROM overton_svd_center").fetchone()[0] drift = compute_drift_metrics(annual_centers)
logger.info("Created overton_svd_center table with %d rows", n_rows)
# Chart
chart_path = str(REPORTS_DIR / "svd_drift_chart.png") chart_path = str(REPORTS_DIR / "svd_drift_chart.png")
plot_trajectory(centers, stability_details, avg_corrs, chart_path) plot_trajectory(annual_centers, chart_path)
drift = compute_drift_metrics(centers)
# Report
report_path = str(REPORTS_DIR / "svd_stability_report.md") report_path = str(REPORTS_DIR / "svd_stability_report.md")
write_report( write_report(centers, annual_centers, drift, report_path, chart_path, non_annual)
is_stable, stability_details, avg_corrs, centers,
drift, report_path, chart_path,
)
summary = { summary = {
"stability_status": "STABLE" if is_stable else "UNSTABLE", "method": "Procrustes-aligned PCA",
"unstable_pairs": unstable_count, "total_windows": len(windows),
"total_pairs": len(stability_details), "annual_windows_analyzed": len(annual_centers),
"mean_axis1_corr": round(avg_corrs["mean_axis1_corr"], 4), "non_annual_skipped": sorted(non_annual),
"mean_axis2_corr": round(avg_corrs["mean_axis2_corr"], 4), "parties_loaded": len(scores),
"windows": len(windows), "windows": windows,
"table_rows": n_rows,
"net_displacement": drift.get("net_displacement"), "net_displacement": drift.get("net_displacement"),
"net_dx": drift.get("net_dx"), "net_dx": drift.get("net_dx"),
"net_dy": drift.get("net_dy"), "net_dy": drift.get("net_dy"),
"angular_direction_deg": drift.get("angular_direction_deg"), "angular_direction_deg": drift.get("angular_direction_deg"),
"approach_to_right": drift.get("approach_to_right"),
} }
logger.info("Summary: %s", json.dumps(summary, indent=2)) logger.info("Summary: %s", json.dumps(summary, indent=2))
return summary return summary
finally:
con.close()
if __name__ == "__main__": if __name__ == "__main__":
result = main() result = main()

@ -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" |

@ -1,6 +1,6 @@
# Overton Window Breakpoint Analysis # 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. and content extremity for right-wing motions in the Tweede Kamer.
**Analysis period:** 2016–2026 **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) ## 1. Yearly Aggregate Metrics (All Right-Wing Motions)
| Year | N (RW) | Centrist Support | Pass Rate | Extremity | Right Support | Left Opp. | | Year | N (RW) | Centrist Support | Extremity | Right Support | Left Opp. |
|------|--------|-----------------|-----------|-----------|---------------|----------| |------|--------|-----------------|-----------|---------------|----------|
| 2016 | 6 | 0.722 | 1.000 | 2.00 | 1.000 | 0.708 | | 2016 | 6 | 0.722 | 2.00 | 1.000 | 0.708 |
| 2017 | 0 | N/A | N/A | N/A | N/A | N/A | | 2017 | 0 | N/A | N/A | N/A | N/A |
| 2018 | 5 | 1.000 | 1.000 | 1.40 | 0.800 | 0.480 | | 2018 | 5 | 1.000 | 1.40 | 0.800 | 0.480 |
| 2019 | 195 | 0.410 | 0.969 | 2.14 | 0.838 | 0.746 | | 2019 | 195 | 0.410 | 2.14 | 0.838 | 0.746 |
| 2020 | 469 | 0.326 | 0.979 | 2.26 | 0.818 | 0.758 | | 2020 | 469 | 0.326 | 2.26 | 0.818 | 0.758 |
| 2021 | 425 | 0.339 | 0.962 | 2.24 | 0.903 | 0.788 | | 2021 | 425 | 0.339 | 2.24 | 0.903 | 0.788 |
| 2022 | 446 | 0.404 | 0.926 | 2.16 | 0.891 | 0.820 | | 2022 | 446 | 0.404 | 2.16 | 0.891 | 0.820 |
| 2023 | 365 | 0.457 | 0.962 | 2.24 | 0.900 | 0.821 | | 2023 | 365 | 0.457 | 2.24 | 0.900 | 0.821 |
| 2024 | 469 | 0.670 | 1.000 | 1.99 | 0.885 | 0.756 | | 2024 | 469 | 0.670 | 1.99 | 0.885 | 0.756 |
| 2025 | 455 | 0.597 | 0.996 | 2.25 | 0.895 | 0.799 | | 2025 | 455 | 0.597 | 2.25 | 0.895 | 0.799 |
| 2026 | 151 | 0.518 | 0.927 | 2.33 | 0.916 | 0.834 | | 2026 | 151 | 0.518 | 2.33 | 0.916 | 0.834 |
## 2. Pre/Post 2024 Comparison ## 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 | | Metric | Pre-2024 Mean | Post-2024 Mean | Δ | Cohen's d |
|--------|--------------|---------------|-----|-----------| |--------|--------------|---------------|-----|-----------|
| Centrist Support | 0.384 | 0.618 | +0.234 | +0.68 | | 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 | | 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). **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 | | 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 | | 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 | | Extremity | 2.28 | 2.18 | -0.10 | -0.14 | 1295 / 405 |
**Interpretation gate:** If opposition metrics also rise post-2024, the shift is not **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. 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 |
|--------|-----------------|------------------|------|-------------|-------------|------| |--------|-----------------|------------------|------|
| Migration | 0.303 | 0.536 | +0.233 | 0.981 | 0.975 | -0.006 | | Migration | 0.303 | 0.536 | +0.233 |
| Non-migration | 0.529 | 0.605 | +0.076 | 0.969 | 0.974 | +0.005 | | Non-migration | 0.529 | 0.605 | +0.076 |
## 5. Extremity-Stratified Pass Rate ## 5. Extremity-Stratified Centrist Support
| Bucket | Period | N | Pass Rate | Δ (post-pre) | | Bucket | Period | N | Mean CS | Median CS | P25 | P75 |
|--------|--------|---|-----------|-------------| |--------|--------|---|---------|-----------|---|-----|
| 1-2 (mild) | Pre-2024 | 221 | 0.950 | | | 1-2 (mild) | Pre-2024 | 221 | 0.522 | 0.400 | 0.250 | 1.000 |
| | Post-2024 | 181 | 1.000 | +0.050 | | | Post-2024 | 181 | 0.775 | 1.000 | 0.600 | 1.000 |
| 2-3 (moderate) | Pre-2024 | 1205 | 0.949 | | | 2-3 (moderate) | Pre-2024 | 1205 | 0.403 | 0.250 | 0.000 | 0.750 |
| | Post-2024 | 640 | 0.983 | +0.033 | | | Post-2024 | 640 | 0.606 | 0.600 | 0.250 | 1.000 |
| 3-4 (high) | Pre-2024 | 352 | 0.983 | | | 3-4 (high) | Pre-2024 | 352 | 0.295 | 0.250 | 0.000 | 0.500 |
| | Post-2024 | 175 | 0.994 | +0.011 | | | Post-2024 | 175 | 0.565 | 0.600 | 0.250 | 0.800 |
| 4-5 (extreme) | Pre-2024 | 133 | 0.992 | | | 4-5 (extreme) | Pre-2024 | 133 | 0.212 | 0.250 | 0.000 | 0.250 |
| | Post-2024 | 79 | 0.987 | -0.005 | | | Post-2024 | 79 | 0.474 | 0.500 | 0.250 | 0.800 |
**Key test:** If high-extremity motions (3–5) went from low pass rate to high pass rate **Key test:** If centrist support for high-extremity motions (3-5) rose
while mild motions stayed flat, centrists are more tolerant of extreme content — disproportionately post-2024 while centrist support for mild motions stayed flat,
direct Overton shift evidence. If pass rate rose uniformly across all buckets, the centrists are more tolerant of extreme content — direct Overton shift evidence.
shift is about quantity, not tolerance. If only the 1–2 bucket rose, right-wing 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. parties filed milder motions post-2024 and the 'shift' is illusory.
## 6. Manual Extremity Audit ## 6. Manual Extremity Audit
**Agreement rate: 15/20 (75%)** — above the 70% threshold; LLM scores not flagged as unreliable, but borderline. **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.
**Identified systematic biases:** | # | Year | Category | LLM Score | Bucket | Agreed? | Driver |
- **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"). | 1 | 2024 | economie/belasting | 1 | 1-2 (mild) | | |
- **Climate topic inflation:** Technical environmental motions scored higher than warranted (e.g., "emissiegegevens beter afbakenen" scored 3, should be 2). | 2 | 2020 | economie/belasting | 1 | 1-2 (mild) | | |
| 3 | 2019 | veiligheid/justitie | 1 | 1-2 (mild) | | |
**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. | 4 | 2025 | economie/belasting | 1 | 1-2 (mild) | | |
| 5 | 2022 | sociaal/jeugd | 1 | 1-2 (mild) | | |
| # | Year | Category | LLM Score | Bucket | Agreed? | Driver | Notes | | 6 | 2021 | corona/pandemie | 2 | 2-3 (moderate) | | |
|---|------|----------|-----------|--------|---------|--------|-------| | 7 | 2021 | zorg/gezondheid | 2 | 2-3 (moderate) | | |
| 1 | 2024 | economie/belasting | 1 | 1-2 (mild) | Y | Policy | EU directive implementation; routine | | 8 | 2020 | economie/belasting | 2 | 2-3 (moderate) | | |
| 2 | 2020 | economie/belasting | 1 | 1-2 (mild) | Y | Policy | Symbolic support for KLM; mild | | 9 | 2025 | veiligheid/justitie | 2 | 2-3 (moderate) | | |
| 3 | 2019 | veiligheid/justitie | 1 | 1-2 (mild) | Y | Policy | Budget procedural; trivial | | 10 | 2020 | economie/belasting | 2 | 2-3 (moderate) | | |
| 4 | 2025 | economie/belasting | 1 | 1-2 (mild) | Y | Policy | Tax bracket indexing; routine | | 11 | 2020 | veiligheid/justitie | 3 | 3-4 (high) | | |
| 5 | 2022 | sociaal/jeugd | 1 | 1-2 (mild) | Y | Policy | One-time parent benefit; limited scope | | 12 | 2025 | klimaat/milieu | 3 | 3-4 (high) | | |
| 6 | 2021 | corona/pandemie | 2 | 2-3 (moderate) | Y | Policy | Sport venue regulation; moderate | | 13 | 2019 | asiel/vreemdelingen | 3 | 3-4 (high) | | |
| 7 | 2021 | zorg/gezondheid | 2 | 2-3 (moderate) | N (→1) | Language | Voluntary vaccination for at-risk; COVID rhetoric inflates | | 14 | 2019 | landbouw/stikstof | 3 | 3-4 (high) | | |
| 8 | 2020 | economie/belasting | 2 | 2-3 (moderate) | Y | Policy | Government influence on port; moderate | | 15 | 2020 | klimaat/milieu | 3 | 3-4 (high) | | |
| 9 | 2025 | veiligheid/justitie | 2 | 2-3 (moderate) | Y | Both | Police oath reform; symbolic + mild policy | | 16 | 2020 | veiligheid/justitie | 4 | 4-5 (extreme) | | |
| 10 | 2020 | economie/belasting | 2 | 2-3 (moderate) | Y | Policy | Corporate tax carryback; narrow fiscal | | 17 | 2021 | defensie/buitenland | 4 | 4-5 (extreme) | | |
| 11 | 2020 | veiligheid/justitie | 3 | 3-4 (high) | N (→2) | Language | Motion of no-confidence is parliamentary procedure, not radical policy | | 18 | 2023 | asiel/vreemdelingen | 4 | 4-5 (extreme) | | |
| 12 | 2025 | klimaat/milieu | 3 | 3-4 (high) | N (→2) | Policy | Emission data scoping; narrow technical fix, inflated by climate topic | | 19 | 2025 | asiel/vreemdelingen | 4 | 4-5 (extreme) | | |
| 13 | 2019 | asiel/vreemdelingen | 3 | 3-4 (high) | Y | Policy | Withdraw from UN Refugee Pact; substantive | | 20 | 2019 | sociaal/jeugd | 4 | 4-5 (extreme) | | |
| 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
## 7. Limitations ## 7. Limitations
@ -140,26 +132,12 @@ parties filed milder motions post-2024 and the 'shift' is illusory.
complex title formats. complex title formats.
- **Keyword penetration not analyzed:** The right-wing keyword set was derived - **Keyword penetration not analyzed:** The right-wing keyword set was derived
differentially from right-wing motions, making it circular for adoption analysis. 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 ## 8. Figures
![Figure 1: Centrist Support and Pass Rate](breakpoint_figure_1.png) ![Figure 1: Centrist Support Over Time](breakpoint_figure_1.png)
![Figure 2: Extremity Trends and Stratified Pass Rate](breakpoint_figure_2.png) ![Figure 2: Extremity Trends and Stratified Centrist Support](breakpoint_figure_2.png)
## 9. Conclusion ## 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. *(Fill in after reviewing all indicators and audit results.)*
### 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.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 154 KiB

@ -8,7 +8,7 @@
## 1. Summary ## 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). 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 ### 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. 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 ### 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 ### 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. 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) ![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 ## 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. **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.
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.
### What we cannot say ### Tier 3 — Weak/noisy
1. **We cannot claim spatial (SVD) drift.** Axes are too unstable for cross-window comparison. 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.
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.
### Uncertainty hierarchy ### Uncertainty hierarchy
| Evidence Level | Indicator | Status | | Evidence Level | Indicator | Status |
|---------------|-----------|--------| |---------------|-----------|--------|
| **Strong** | Centrist support surge (opposition-controlled) | Confirmed | | **Strong** | Centrist support surge (opposition-controlled) | Confirmed |
| **Strong** | Spatial divergence — acceptance without conversion | Confirmed |
| **Moderate** | Migration-specificity of the shift | Confirmed | | **Moderate** | Migration-specificity of the shift | Confirmed |
| **Inconclusive** | Extremity-stratified tolerance shift | Underpowered (pass rate ceiling) | | **Inconclusive** | Extremity-stratified tolerance shift | Gradient persists, baseline-shifted |
| **Inconclusive** | SVD spatial drift | Axes unstable | | **Weak** | Content extremity trend | No increase (LLM biases, 75% audit) |
| **Weak** | Content extremity trend | No increase (but LLM scoring imperfect) |
--- ---
@ -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. - **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. - **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. - **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. - **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 ## 7. Figures
1. `breakpoint_figure_1.png` — Centrist support and pass rate over time (all RW, opposition-only, migration, non-migration, + baseline) 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 pass rate (pre vs. post 2024) 2. `breakpoint_figure_2.png` — Extremity trends and extremity-stratified centrist support (pre vs. post 2024)
3. `svd_drift_chart.png`SVD centrist center trajectory (unreliable — axes unstable) 3. `svd_drift_chart.png`Procrustes-aligned centrist center trajectory (see Section 4)
--- ---
## 8. Next Steps ## 8. Next Steps
1. **Commit findings** and archive the analysis on `feat/right-wing-motion-analysis`. 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. **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. 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. **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. 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.
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.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 125 KiB

@ -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 ## Axis Interpretation
- Mean axis-2 correlation: 0.2128
### 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 | - **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.
| 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 | > **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.
| 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 |
## Centrist Center of Gravity ## 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 | | 2016 | 0.3395 | 0.0103 | 0.1321 | -0.2716 | BBB, CDA, ChristenUnie, D66, NSC, VVD | FVD, JA21, PVV, SGP |
| 2017 | -3.6524 | -0.7100 | -3.7095 | 1.3546 | BBB, CDA, ChristenUnie, D66, NSC, VVD | FVD, PVV, SGP | | 2017 | 0.2623 | 0.0278 | 0.0981 | -0.3418 | BBB, CDA, ChristenUnie, D66, NSC, VVD | FVD, JA21, PVV, SGP |
| 2018 | 5.1662 | -0.8347 | 2.2336 | 3.7263 | BBB, CDA, ChristenUnie, D66, NSC, VVD | FVD, PVV, SGP | | 2018 | 0.2844 | 0.1560 | 0.0724 | -0.3819 | BBB, CDA, ChristenUnie, D66, NSC, VVD | FVD, JA21, PVV, SGP |
| 2019 | 11.7312 | -2.6126 | 7.1092 | 6.5966 | BBB, CDA, ChristenUnie, D66, NSC, VVD | FVD, PVV, SGP | | 2019 | 0.0535 | 0.0446 | -0.0361 | -0.2754 | BBB, CDA, ChristenUnie, D66, NSC, VVD | FVD, JA21, PVV, SGP |
| 2020 | -27.1482 | 2.8304 | -9.2387 | -14.1063 | BBB, CDA, ChristenUnie, D66, NSC, VVD | FVD, PVV, SGP | | 2020 | 0.2615 | 0.1170 | -0.0858 | -0.3468 | BBB, CDA, ChristenUnie, D66, NSC, VVD | FVD, JA21, PVV, SGP |
| 2021 | -15.9032 | -0.6795 | -6.3142 | 19.8728 | 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 | -28.5270 | -0.6204 | -3.8504 | 19.8373 | 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 | -15.7130 | 0.1571 | -5.0623 | -15.8667 | 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 | 26.2822 | 19.2003 | 24.9072 | -0.8372 | BBB, CDA, ChristenUnie, D66, NSC, VVD | FVD, JA21, PVV, SGP | | 2024 | 0.2122 | 0.1209 | 0.1295 | -0.3524 | BBB, CDA, ChristenUnie, D66, NSC, VVD | FVD, PVV, SGP |
| 2025 | -8.1667 | 12.0249 | -14.5604 | 1.7604 | BBB, CDA, ChristenUnie, D66, NSC, VVD | FVD, JA21, PVV, SGP | | 2025 | 0.0214 | -0.0010 | 0.0323 | -0.3755 | BBB, CDA, ChristenUnie, D66, NSC, VVD | FVD, PVV, SGP |
| 2026 | -13.6251 | 3.4321 | -3.8011 | 16.7538 | BBB, CDA, ChristenUnie, D66, NSC, VVD | FVD, JA21, 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 (UNRELIABLE — Axes Unstable)
## Drift Metrics (Annual Windows Only)
Drift metrics were computed but are unreliable due to axis instability. Cross-window comparisons on unstable axes conflate positional change with axis re-orientation.
- **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 ## 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 ## 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*

Loading…
Cancel
Save