feat(analysis): Overton window breakpoint analysis with opposition control and SVD drift

Quantify 2024 breakpoint in centrist support (d=+0.68 overall, d=+0.85 opposition-only),
domain decomposition, extremity-stratified pass rates, and manual LLM audit (75% agreement).
SVD center drift aborted due to axis instability (9/10 consecutive window pairs fail stability threshold).
main
Sven Geboers 1 month ago
parent d170444bda
commit 76b499cdc0
  1. 977
      analysis/right_wing/overton_breakpoint_analysis.py
  2. 617
      analysis/right_wing/overton_svd_drift.py
  3. 252
      docs/plans/2026-05-08-001-feat-visualize-migration-antidemocratic-plan.md
  4. 367
      docs/plans/2026-05-08-002-feat-overton-window-shift-plan.md
  5. 165
      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. 134
      reports/overton_window/findings_report.md
  9. BIN
      reports/overton_window/svd_drift_chart.png
  10. 60
      reports/overton_window/svd_stability_report.md

@ -0,0 +1,977 @@
#!/usr/bin/env python3
"""U2: Quantify the 2024 Overton Window breakpoint in Dutch parliament.
Descriptive analysis of centrist support, pass rates, and content extremity
for right-wing motions with coalition control via opposition-only filtering,
domain decomposition, and a baseline comparison.
Usage:
uv run python analysis/right_wing/overton_breakpoint_analysis.py
Output:
reports/overton_window/breakpoint_analysis.md
reports/overton_window/breakpoint_figure_1.png
reports/overton_window/breakpoint_figure_2.png
"""
from __future__ import annotations
import json
import logging
import random
import re
import sys
from pathlib import Path
from typing import Any
import duckdb
import matplotlib
import numpy as np
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import matplotlib.ticker as mticker
ROOT = Path(__file__).parent.parent.parent.resolve()
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from analysis.config import CANONICAL_LEFT, CANONICAL_RIGHT, PARTY_COLOURS
CANONICAL_CENTRIST = frozenset({"VVD", "D66", "CDA", "NSC", "BBB", "CU"})
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger(__name__)
DB_PATH = str(ROOT / "data" / "motions.db")
REPORTS_DIR = ROOT / "reports" / "overton_window"
REPORTS_DIR.mkdir(parents=True, exist_ok=True)
CANONICAL_CENTRIST_SET = set(CANONICAL_CENTRIST) # nb: config defines as frozenset
CANONICAL_LEFT_SET = set(CANONICAL_LEFT)
CANONICAL_RIGHT_SET = set(CANONICAL_RIGHT)
COALITION: dict[int, set[str]] = {
2016: {"VVD", "PvdA"},
2017: {"VVD", "PvdA"},
2018: {"VVD", "CDA", "D66", "CU"},
2019: {"VVD", "CDA", "D66", "CU"},
2020: {"VVD", "CDA", "D66", "CU"},
2021: {"VVD", "CDA", "D66", "CU"},
2022: {"VVD", "D66", "CDA", "CU"},
2023: {"VVD", "D66", "CDA", "CU"},
2024: {"PVV", "VVD", "NSC", "BBB"},
2025: {"PVV", "VVD", "NSC", "BBB"},
2026: {"PVV", "VVD", "NSC", "BBB"},
}
COALITION_NOTE = (
"2016-2017: Rutte II (VVD/PvdA). "
"2018-2021: Rutte III (VVD/CDA/D66/CU). "
"2022-2023: Rutte IV (VVD/D66/CDA/CU). "
"2024-2026: Schoof (PVV/VVD/NSC/BBB). "
"2024 ambiguous: Schoof cabinet started July 2024; all 2024 motions are coded "
"to the Schoof coalition. Coalition effect may be overestimated for early 2024."
)
YEAR_MIN, YEAR_MAX = 2016, 2026
BREAK_YEAR = 2024
def _conn(read_only: bool = True) -> duckdb.DuckDBPyConnection:
return duckdb.connect(DB_PATH, read_only=read_only)
def cohens_d(x: np.ndarray, y: np.ndarray) -> float:
"""Cohen's d effect size."""
pooled = np.sqrt((np.var(x, ddof=1) + np.var(y, ddof=1)) / 2)
if pooled == 0:
return 0.0
return (np.mean(y) - np.mean(x)) / pooled
def compute_yearly_rw_metrics(con: duckdb.DuckDBPyConnection) -> dict[int, dict]:
"""Yearly aggregates for classified right-wing motions.
Joins right_wing_motions with extremity_scores and motions (for pass rate).
"""
rows = con.execute("""
SELECT
r.motion_id,
r.year,
r.title,
r.centrist_support,
r.right_support,
r.left_opposition,
r.category,
e.text_score AS extremity_score,
m.voting_results,
m.winning_margin
FROM right_wing_motions r
JOIN extremity_scores e ON r.motion_id = e.motion_id
JOIN motions m ON r.motion_id = m.id
WHERE r.classified = TRUE
AND r.year IS NOT NULL
AND e.text_score IS NOT NULL
""").fetchall()
yearly: dict[int, dict[str, Any]] = {}
for year in range(YEAR_MIN, YEAR_MAX + 1):
yearly[year] = {
"centrist_support": [],
"right_support": [],
"left_opposition": [],
"extremity": [],
"passed": [],
"categories": [],
"titles": [],
"motion_ids": [],
}
for mid, year, title, cs, rs, lo, cat, ext, vr_json, wm in rows:
if year is None or year < YEAR_MIN or year > YEAR_MAX:
continue
yearly[year]["centrist_support"].append(cs if cs is not None else np.nan)
yearly[year]["right_support"].append(rs if rs is not None else np.nan)
yearly[year]["left_opposition"].append(lo if lo is not None else np.nan)
yearly[year]["extremity"].append(ext if ext is not None else np.nan)
yearly[year]["categories"].append(cat or "other")
yearly[year]["titles"].append(title or "")
yearly[year]["motion_ids"].append(mid)
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)
return yearly
def compute_yearly_baseline(con: duckdb.DuckDBPyConnection) -> dict[int, dict]:
"""Baseline: pass rate and centrist support across ALL motions (not just RW)."""
rows = con.execute("""
SELECT
m.id AS motion_id,
EXTRACT(YEAR FROM m.date) AS year,
m.voting_results,
m.winning_margin
FROM motions m
WHERE m.date IS NOT NULL
""").fetchall()
yearly: dict[int, dict] = {}
for year in range(YEAR_MIN, YEAR_MAX + 1):
yearly[year] = {"passed": [], "centrist_support": []}
for mid, year, vr_json, wm in rows:
if year is None or int(year) < YEAR_MIN or int(year) > YEAR_MAX:
continue
year = int(year)
if vr_json is not None:
voting = json.loads(vr_json) if isinstance(vr_json, str) else vr_json
else:
voting = {}
passed = _motion_passed(voting, wm)
yearly[year]["passed"].append(passed)
centrist_rows = con.execute("""
SELECT
mv.motion_id,
EXTRACT(YEAR FROM mv.date) AS year,
mv.party,
COUNT(*) AS n,
mv.vote
FROM mp_votes mv
WHERE mv.party IS NOT NULL
AND mv.date IS NOT NULL
GROUP BY mv.motion_id, EXTRACT(YEAR FROM mv.date), mv.party, mv.vote
""").fetchall()
motion_party_votes: dict[int, dict[str, dict[str, int]]] = {}
for mid, year, party, n, vote in centrist_rows:
year = int(year)
if year < YEAR_MIN or year > YEAR_MAX:
continue
mv = motion_party_votes.setdefault(mid, {})
pv = mv.setdefault(party, {"voor": 0, "tegen": 0, "afwezig": 0})
pv[vote] = pv.get(vote, 0) + n
motion_year_map: dict[int, int] = {}
for mid, year, _, _, _ in centrist_rows:
year = int(year)
if YEAR_MIN <= year <= YEAR_MAX:
motion_year_map[mid] = year
for mid, votes in motion_party_votes.items():
year = motion_year_map.get(mid)
if year is None:
continue
cs = _support_ratio(votes, CANONICAL_CENTRIST_SET)
if cs is not None:
yearly[year]["centrist_support"].append(cs)
return yearly
def _motion_passed(
voting: dict[str, str], winning_margin: float | None = None
) -> bool | None:
"""Determine if a motion passed from voting_results or winning_margin."""
if winning_margin is not None:
return winning_margin > 0
voor = sum(1 for v in voting.values() if v == "voor")
tegen = sum(1 for v in voting.values() if v == "tegen")
if voor + tegen == 0:
return None
return voor > tegen
def _support_ratio(
votes: dict[str, dict[str, int]], parties: set[str]
) -> float | None:
"""Compute support ratio (fraction of parties voting 'voor')."""
total = 0
supportive = 0
for party, pv in votes.items():
if party not in parties:
continue
tv = pv.get("voor", 0) + pv.get("tegen", 0) + pv.get("afwezig", 0)
if tv == 0:
continue
total += 1
if pv.get("voor", 0) / tv >= 0.5:
supportive += 1
if total == 0:
return None
return supportive / total
def build_party_name_map(con: duckdb.DuckDBPyConnection) -> dict[str, str]:
"""Build mapping: last name -> party from mp_metadata."""
rows = con.execute("""
SELECT mp_name, party, van, tot_en_met
FROM mp_metadata
WHERE party IS NOT NULL
ORDER BY tot_en_met DESC NULLS LAST, van DESC NULLS LAST
""").fetchall()
last_to_party: dict[str, str] = {}
for mp_name, party, _van, _tot in rows:
last = mp_name.split(",")[0].strip()
if last not in last_to_party:
last_to_party[last] = party
return last_to_party
def parse_lead_submitter(
title: str, name_party_map: dict[str, str]
) -> tuple[str | None, str | None]:
"""Parse the lead submitter from a motion title and map to party.
Returns (parsed_name, party) or (None, None).
"""
if not title:
return None, None
patterns = [
r"(?:Gewijzigde|Nader\s+gewijzigde)?\s*Motie\s+van\s+het\s+lid\s+(.+?)\s+(?:c\.s\.\s+)?over\b",
r"(?:Gewijzigde|Nader\s+gewijzigde)?\s*Motie\s+van\s+de\s+leden\s+(.+?)\s+(?:c\.s\.\s+)?over\b",
r"Amendement\s+van\s+het\s+lid\s+(.+?)\s+over\b",
r"Amendement\s+van\s+de\s+leden\s+(.+?)\s+over\b",
]
for pat in patterns:
m = re.search(pat, title)
if m:
submitter_str = m.group(1).strip()
parts = submitter_str.split(" en ")
first_name = parts[0].strip()
first_name = re.sub(r"\s+c\.s\.", "", first_name).strip()
if not first_name:
continue
party = name_party_map.get(first_name)
return first_name, party
return None, None
def compute_opposition_metrics(
yearly_raw: dict[int, dict], name_party_map: dict[str, str]
) -> dict[int, dict]:
"""Recompute yearly metrics for opposition-only right-wing motions.
Filters motions where the lead submitter's party is NOT in the coalition.
"""
opp: dict[int, dict[str, list]] = {}
for year in range(YEAR_MIN, YEAR_MAX + 1):
opp[year] = {
"centrist_support": [],
"extremity": [],
"passed": [],
"n": 0,
}
coalition = COALITION
year_titles_map: dict[int, list[int]] = {}
for year, d in yearly_raw.items():
year_titles_map[year] = list(range(len(d["titles"])))
for year, d in yearly_raw.items():
coal = coalition.get(year, set())
for idx in range(len(d["titles"])):
title = d["titles"][idx]
submitter_name, submitter_party = parse_lead_submitter(title, name_party_map)
if submitter_party is None:
continue
if submitter_party in coal:
continue
opp[year]["centrist_support"].append(d["centrist_support"][idx])
opp[year]["extremity"].append(d["extremity"][idx])
opp[year]["passed"].append(d["passed"][idx])
opp[year]["n"] += 1
return opp
def compute_domain_metrics(
yearly_raw: dict[int, dict],
) -> tuple[dict[int, dict], dict[int, dict]]:
"""Split into migration and non-migration domains."""
mig: dict[int, dict[str, list]] = {}
non_mig: dict[int, dict[str, list]] = {}
for year in range(YEAR_MIN, YEAR_MAX + 1):
mig[year] = {"centrist_support": [], "extremity": [], "passed": [], "n": 0}
non_mig[year] = {"centrist_support": [], "extremity": [], "passed": [], "n": 0}
for year, d in yearly_raw.items():
for idx in range(len(d["titles"])):
cat = d["categories"][idx]
target = mig if cat == "asiel/vreemdelingen" else non_mig
target[year]["centrist_support"].append(d["centrist_support"][idx])
target[year]["extremity"].append(d["extremity"][idx])
target[year]["passed"].append(d["passed"][idx])
target[year]["n"] += 1
return mig, non_mig
def compute_extremity_stratified(
yearly_raw: dict[int, dict],
) -> dict[str, dict[str, list]]:
"""Compute pass rate per extremity bucket, pre vs post 2024."""
buckets = {
"1-2 (mild)": [],
"2-3 (moderate)": [],
"3-4 (high)": [],
"4-5 (extreme)": [],
}
pre_post: dict[str, dict[str, list]] = {
"pre-2024": {b: [] for b in buckets},
"post-2024": {b: [] for b in buckets},
}
for year, d in yearly_raw.items():
period = "pre-2024" if year < BREAK_YEAR else "post-2024"
for idx in range(len(d["titles"])):
ext = d["extremity"][idx]
passed = d["passed"][idx]
if np.isnan(ext) or passed is None:
continue
if ext < 2:
b = "1-2 (mild)"
elif ext < 3:
b = "2-3 (moderate)"
elif ext < 4:
b = "3-4 (high)"
else:
b = "4-5 (extreme)"
pre_post[period][b].append(passed)
return pre_post
def yearly_summary(yearly: dict[int, dict]) -> dict[int, dict]:
"""Compute mean values from raw lists."""
summary: dict[int, dict] = {}
for year, d in yearly.items():
s: dict[str, Any] = {}
for key in ["centrist_support", "right_support", "left_opposition", "extremity"]:
vals = [v for v in d.get(key, []) if not (isinstance(v, float) and np.isnan(v))]
s[f"mean_{key}"] = np.mean(vals) if vals else float("nan")
passes = [p for p in d.get("passed", []) if p is not None]
s["pass_rate"] = sum(passes) / len(passes) if passes else float("nan")
s["n"] = len(d.get("motion_ids", d.get("centrist_support", [])))
summary[year] = s
return summary
def sample_audit(yearly_raw: dict[int, dict]) -> list[dict]:
"""Stratified random sample: 5 motions per extremity bucket, 20 total."""
bucket_motions: dict[str, list[int]] = {
"1-2 (mild)": [],
"2-3 (moderate)": [],
"3-4 (high)": [],
"4-5 (extreme)": [],
}
all_motions: list[dict] = []
for year, d in yearly_raw.items():
for idx in range(len(d["titles"])):
ext = d["extremity"][idx]
if np.isnan(ext):
continue
if ext < 2:
b = "1-2 (mild)"
elif ext < 3:
b = "2-3 (moderate)"
elif ext < 4:
b = "3-4 (high)"
else:
b = "4-5 (extreme)"
bucket_motions[b].append(len(all_motions))
all_motions.append({
"year": year,
"title": d["titles"][idx],
"category": d["categories"][idx],
"extremity": ext,
})
rng = random.Random(42)
sampled: list[dict] = []
for bucket_name, indices in bucket_motions.items():
n_sample = min(5, len(indices))
chosen = rng.sample(indices, n_sample) if indices else []
for idx in chosen:
m = all_motions[idx].copy()
m["bucket"] = bucket_name
sampled.append(m)
sampled.sort(key=lambda x: (x["bucket"], x["extremity"]))
return sampled
def print_audit(sampled: list[dict]) -> None:
"""Display sampled motions for manual extremity audit."""
print("\n" + "=" * 80)
print(" MANUAL EXTREMITY AUDIT")
print("=" * 80)
print()
print("For each motion below, judge whether you agree with the LLM-assigned extremity bucket.")
print("Also note: does the score reflect stylistic extremity (language) or material impact (policy)?")
print()
from itertools import groupby
for bucket, group in groupby(sampled, key=lambda m: m["bucket"]):
group_list = list(group)
print(f"\n--- {bucket} (n={len(group_list)} sampled) ---")
for i, m in enumerate(group_list, 1):
title = m["title"][:120]
print(f"\n [{i}] Year={m['year']} | Category={m['category']}")
print(f" LLM Score: {m['extremity']}")
print(f" Title: {title}")
print(f" Agree? [Y/N] Driven by: Language / Policy / Both")
print("\n" + "=" * 80)
print(" END OF AUDIT — Record agreement rate and note systematic biases")
print("=" * 80)
def create_figure_1(
yearly_sum: dict[int, dict],
opp_sum: dict[int, dict],
mig_sum: dict[int, dict],
non_mig_sum: dict[int, dict],
baseline_sum: dict[int, dict],
) -> str:
"""Figure 1: Centrist support + Pass rate over time (2 panels)."""
years = sorted(yearly_sum.keys())
years_arr = np.array(years)
def _vals(summary, key):
return np.array([summary[y].get(key, np.nan) for y in years])
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 10), sharex=True)
colour_all = "grey"
colour_rw = "#002366"
colour_opp = "#E53935"
colour_mig = "#6A1B9A"
colour_non_mig = "#4CAF50"
colour_baseline = "#9E9E9E"
# Panel A: Centrist support
ax1.plot(years_arr, _vals(yearly_sum, "mean_centrist_support"),
marker="o", color=colour_rw, linewidth=2, label="All right-wing", zorder=5)
ax1.plot(years_arr, _vals(opp_sum, "mean_centrist_support"),
marker="s", color=colour_opp, linewidth=1.5, linestyle="--", label="Opposition-only RW", zorder=4)
ax1.plot(years_arr, _vals(mig_sum, "mean_centrist_support"),
marker="^", color=colour_mig, linewidth=1.5, linestyle=":", label="Migration", zorder=3)
ax1.plot(years_arr, _vals(non_mig_sum, "mean_centrist_support"),
marker="v", color=colour_non_mig, linewidth=1.5, linestyle="-.", label="Non-migration", zorder=2)
ax1.plot(years_arr, _vals(baseline_sum, "mean_centrist_support"),
color=colour_baseline, linewidth=1, linestyle="dashed", alpha=0.7, zorder=1, label="All motions (baseline)")
ax1.axvline(x=BREAK_YEAR - 0.5, color="black", linestyle=":", alpha=0.5, linewidth=1)
ax1.annotate("2024", xy=(BREAK_YEAR - 0.3, ax1.get_ylim()[1] * 0.95 if ax1.get_ylim()[1] > 0 else 0.95),
fontsize=9, color="black", alpha=0.7)
ax1.set_ylabel("Mean Centrist Support")
ax1.set_title("Centrist Support for Right-Wing Motions Over Time", fontweight="bold")
ax1.legend(loc="lower right", fontsize=8, ncol=2)
ax1.set_ylim(0, 1.05)
ax1.grid(True, alpha=0.3)
# Panel B: Pass rate
ax2.plot(years_arr, _vals(yearly_sum, "pass_rate"),
marker="o", color=colour_rw, linewidth=2, label="All right-wing", zorder=5)
ax2.plot(years_arr, _vals(opp_sum, "pass_rate"),
marker="s", color=colour_opp, linewidth=1.5, linestyle="--", label="Opposition-only RW", zorder=4)
ax2.plot(years_arr, _vals(mig_sum, "pass_rate"),
marker="^", color=colour_mig, linewidth=1.5, linestyle=":", label="Migration", zorder=3)
ax2.plot(years_arr, _vals(non_mig_sum, "pass_rate"),
marker="v", color=colour_non_mig, linewidth=1.5, linestyle="-.", label="Non-migration", zorder=2)
ax2.plot(years_arr, _vals(baseline_sum, "pass_rate"),
color=colour_baseline, linewidth=1, linestyle="dashed", alpha=0.7, zorder=1, label="All motions (baseline)")
ax2.axvline(x=BREAK_YEAR - 0.5, color="black", linestyle=":", alpha=0.5, linewidth=1)
ax2.annotate("2024", xy=(BREAK_YEAR - 0.3, ax2.get_ylim()[1] * 0.95 if ax2.get_ylim()[1] > 0 else 0.95),
fontsize=9, color="black", alpha=0.7)
ax2.set_xlabel("Year")
ax2.set_ylabel("Pass Rate")
ax2.set_title("Pass Rate of Right-Wing Motions Over Time", fontweight="bold")
ax2.legend(loc="lower right", fontsize=8, ncol=2)
ax2.set_ylim(0, 1.05)
ax2.grid(True, alpha=0.3)
ax2.set_xticks(years_arr)
ax2.set_xticklabels([str(y) for y in years], rotation=45)
plt.tight_layout()
path = str(REPORTS_DIR / "breakpoint_figure_1.png")
fig.savefig(path, dpi=150, bbox_inches="tight")
plt.close(fig)
logger.info("Saved Figure 1 to %s", path)
return path
def create_figure_2(
yearly_sum: dict[int, dict],
opp_sum: dict[int, dict],
mig_sum: dict[int, dict],
non_mig_sum: dict[int, dict],
ext_stratified: dict[str, dict[str, list]],
) -> str:
"""Figure 2: Extremity over time + Extremity-stratified pass rate (2 panels)."""
years = sorted(yearly_sum.keys())
years_arr = np.array(years)
def _vals(summary, key):
return np.array([summary[y].get(key, np.nan) for y in years])
colour_rw = "#002366"
colour_opp = "#E53935"
colour_mig = "#6A1B9A"
colour_non_mig = "#4CAF50"
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 6))
# Panel C: Mean extremity over time
ax1.plot(years_arr, _vals(yearly_sum, "mean_extremity"),
marker="o", color=colour_rw, linewidth=2, label="All right-wing", zorder=5)
ax1.plot(years_arr, _vals(opp_sum, "mean_extremity"),
marker="s", color=colour_opp, linewidth=1.5, linestyle="--", label="Opposition-only RW", zorder=4)
ax1.plot(years_arr, _vals(mig_sum, "mean_extremity"),
marker="^", color=colour_mig, linewidth=1.5, linestyle=":", label="Migration", zorder=3)
ax1.plot(years_arr, _vals(non_mig_sum, "mean_extremity"),
marker="v", color=colour_non_mig, linewidth=1.5, linestyle="-.", label="Non-migration", zorder=2)
ax1.axvline(x=BREAK_YEAR - 0.5, color="black", linestyle=":", alpha=0.5, linewidth=1)
ax1.annotate("2024", xy=(BREAK_YEAR - 0.3, ax1.get_ylim()[1] * 0.95 if ax1.get_ylim()[1] > 0 else 4.5),
fontsize=9, color="black", alpha=0.7)
ax1.set_xlabel("Year")
ax1.set_ylabel("Mean Extremity Score")
ax1.set_title("Content Extremity Over Time", fontweight="bold")
ax1.legend(loc="upper left", fontsize=8)
ax1.grid(True, alpha=0.3)
ax1.set_xticks(years_arr)
ax1.set_xticklabels([str(y) for y in years], rotation=45)
# Panel D: Extremity-stratified pass rate (grouped bars)
bucket_order = ["1-2 (mild)", "2-3 (moderate)", "3-4 (high)", "4-5 (extreme)"]
bucket_labels = ["1-2\nmild", "2-3\nmoderate", "3-4\nhigh", "4-5\nextreme"]
bucket_colours = ["#81C784", "#FFB74D", "#E57373", "#BA68C8"]
x = np.arange(len(bucket_order))
width = 0.35
pre_rates = []
pre_ns = []
post_rates = []
post_ns = []
for b in bucket_order:
pre_data = ext_stratified["pre-2024"].get(b, [])
post_data = ext_stratified["post-2024"].get(b, [])
pre_rates.append(np.mean(pre_data) if pre_data else 0)
pre_ns.append(len(pre_data))
post_rates.append(np.mean(post_data) if post_data else 0)
post_ns.append(len(post_data))
bars_pre = ax2.bar(x - width / 2, pre_rates, width, label="Pre-2024 (2016-2023)",
color="#90CAF9", edgecolor="black", alpha=0.9)
bars_post = ax2.bar(x + width / 2, post_rates, width, label="Post-2024 (2024-2026)",
color="#1E88E5", edgecolor="black", alpha=0.9)
for i, (bar, n) in enumerate(zip(bars_pre, pre_ns)):
ax2.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 0.01,
f"N={n}", ha="center", va="bottom", fontsize=8, fontweight="bold")
for i, (bar, n) in enumerate(zip(bars_post, post_ns)):
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")
ax2.set_xticks(x)
ax2.set_xticklabels(bucket_labels)
ax2.set_ylabel("Pass Rate")
ax2.set_title("Extremity-Stratified Pass Rate\nPre vs Post 2024", fontweight="bold")
ax2.legend(fontsize=8)
ax2.set_ylim(0, 1.05)
ax2.grid(True, alpha=0.3, axis="y")
plt.tight_layout()
path = str(REPORTS_DIR / "breakpoint_figure_2.png")
fig.savefig(path, dpi=150, bbox_inches="tight")
plt.close(fig)
logger.info("Saved Figure 2 to %s", path)
return path
def generate_report(
yearly_sum: dict[int, dict],
opp_sum: dict[int, dict],
mig_sum: dict[int, dict],
non_mig_sum: dict[int, dict],
baseline_sum: dict[int, dict],
ext_stratified: dict[str, dict[str, list]],
yearly_raw: dict[int, dict],
opp_raw: dict[int, dict],
fig1_path: str,
fig2_path: str,
audit_sample: list[dict],
audit_notes: str = "",
) -> str:
"""Generate the breakpoint analysis markdown report."""
years = sorted(yearly_sum.keys())
def _val(summary, year, key):
return summary[year].get(key, np.nan)
# Pre/post 2024 comparisons
pre_years = [y for y in years if y < BREAK_YEAR]
post_years = [y for y in years if y >= BREAK_YEAR]
# Pooled pre/post values for Cohen's d
rw_pre_cs = []
rw_post_cs = []
rw_pre_pr = []
rw_post_pr = []
rw_pre_ext = []
rw_post_ext = []
opp_pre_cs = []
opp_post_cs = []
opp_pre_pr = []
opp_post_pr = []
opp_pre_ext = []
opp_post_ext = []
for y, d in yearly_raw.items():
for idx in range(len(d.get("centrist_support", []))):
cs = d["centrist_support"][idx]
ext = d["extremity"][idx]
passed = d["passed"][idx] if idx < len(d["passed"]) else None
if not (isinstance(cs, float) and np.isnan(cs)):
if y < BREAK_YEAR:
rw_pre_cs.append(cs)
else:
rw_post_cs.append(cs)
if not (isinstance(ext, float) and np.isnan(ext)):
if y < BREAK_YEAR:
rw_pre_ext.append(ext)
else:
rw_post_ext.append(ext)
if passed is not None:
if y < BREAK_YEAR:
rw_pre_pr.append(1.0 if passed else 0.0)
else:
rw_post_pr.append(1.0 if passed else 0.0)
for y, d in opp_raw.items():
for idx in range(len(d.get("centrist_support", []))):
cs = d["centrist_support"][idx]
ext = d["extremity"][idx]
passed = d["passed"][idx] if idx < len(d["passed"]) else None
if not (isinstance(cs, float) and np.isnan(cs)):
if y < BREAK_YEAR:
opp_pre_cs.append(cs)
else:
opp_post_cs.append(cs)
if not (isinstance(ext, float) and np.isnan(ext)):
if y < BREAK_YEAR:
opp_pre_ext.append(ext)
else:
opp_post_ext.append(ext)
if passed is not None:
if y < BREAK_YEAR:
opp_pre_pr.append(1.0 if passed else 0.0)
else:
opp_post_pr.append(1.0 if passed else 0.0)
d_cs = cohens_d(np.array(rw_pre_cs), np.array(rw_post_cs))
d_pr = cohens_d(np.array(rw_pre_pr), np.array(rw_post_pr))
d_ext = cohens_d(np.array(rw_pre_ext), np.array(rw_post_ext))
d_opp_cs = cohens_d(np.array(opp_pre_cs), np.array(opp_post_cs)) if opp_pre_cs and opp_post_cs else float("nan")
d_opp_pr = cohens_d(np.array(opp_pre_pr), np.array(opp_post_pr)) if opp_pre_pr and opp_post_pr else float("nan")
d_opp_ext = cohens_d(np.array(opp_pre_ext), np.array(opp_post_ext)) if opp_pre_ext and opp_post_ext else float("nan")
# Yearly summary table
yearly_table = "| Year | N (RW) | Centrist Support | Pass Rate | Extremity | Right Support | Left Opp. |\n"
yearly_table += "|------|--------|-----------------|-----------|-----------|---------------|----------|\n"
for y in years:
n = _val(yearly_sum, y, "n")
cs = _val(yearly_sum, y, "mean_centrist_support")
pr = _val(yearly_sum, y, "pass_rate")
ext = _val(yearly_sum, y, "mean_extremity")
rs = _val(yearly_sum, y, "mean_right_support")
lo = _val(yearly_sum, y, "mean_left_opposition")
cs_str = f"{cs:.3f}" if not np.isnan(cs) else "N/A"
pr_str = f"{pr:.3f}" if not np.isnan(pr) else "N/A"
ext_str = f"{ext:.2f}" if not np.isnan(ext) else "N/A"
rs_str = f"{rs:.3f}" if not np.isnan(rs) else "N/A"
lo_str = f"{lo:.3f}" if not np.isnan(lo) else "N/A"
yearly_table += f"| {y} | {int(n)} | {cs_str} | {pr_str} | {ext_str} | {rs_str} | {lo_str} |\n"
# Extremity-stratified table
bucket_order = ["1-2 (mild)", "2-3 (moderate)", "3-4 (high)", "4-5 (extreme)"]
ext_table = "| Bucket | Period | N | Pass Rate | Δ (post-pre) |\n"
ext_table += "|--------|--------|---|-----------|-------------|\n"
for b in bucket_order:
pre_data = ext_stratified["pre-2024"].get(b, [])
post_data = ext_stratified["post-2024"].get(b, [])
pre_pr = np.mean(pre_data) if pre_data else float("nan")
post_pr = np.mean(post_data) if post_data else float("nan")
delta = post_pr - pre_pr if not np.isnan(pre_pr) and not np.isnan(post_pr) else float("nan")
ext_table += f"| {b} | Pre-2024 | {len(pre_data)} | {pre_pr:.3f} | |\n"
ext_table += f"| | Post-2024 | {len(post_data)} | {post_pr:.3f} | {delta:+.3f} |\n"
# Audit table
audit_table = "| # | Year | Category | LLM Score | Bucket | Agreed? | Driver |\n"
audit_table += "|---|------|----------|-----------|--------|---------|--------|\n"
for i, m in enumerate(audit_sample, 1):
audit_table += f"| {i} | {m['year']} | {m['category']} | {m['extremity']} | {m['bucket']} | | |\n"
lines = [
"# Overton Window Breakpoint Analysis",
"",
"**Goal:** Quantify the 2024 structural break in centrist support, pass rates,",
"and content extremity for right-wing motions in the Tweede Kamer.",
"",
"**Analysis period:** 2016–2026",
"**Right-wing parties:** PVV, FVD, JA21, SGP",
"**Centrist parties:** VVD, D66, CDA, NSC, BBB, CU",
"**Left parties:** PvdA, GL, SP, PvdD, Volt, DENK, Bij1",
"",
"---",
"",
"## 1. Yearly Aggregate Metrics (All Right-Wing Motions)",
"",
yearly_table,
"",
"## 2. Pre/Post 2024 Comparison",
"",
f"**Break year:** {BREAK_YEAR}",
"",
"### All right-wing motions",
"",
f"| Metric | Pre-2024 Mean | Post-2024 Mean | Δ | Cohen's d |",
f"|--------|--------------|---------------|-----|-----------|",
f"| Centrist Support | {np.mean(rw_pre_cs):.3f} | {np.mean(rw_post_cs):.3f} | {np.mean(rw_post_cs) - np.mean(rw_pre_cs):+.3f} | {d_cs:+.2f} |",
f"| Pass Rate | {np.mean(rw_pre_pr):.3f} | {np.mean(rw_post_pr):.3f} | {np.mean(rw_post_pr) - np.mean(rw_pre_pr):+.3f} | {d_pr:+.2f} |",
f"| Extremity | {np.mean(rw_pre_ext):.2f} | {np.mean(rw_post_ext):.2f} | {np.mean(rw_post_ext) - np.mean(rw_pre_ext):+.2f} | {d_ext:+.2f} |",
"",
f"**Interpretation:** Cohen's d values quantify effect sizes (|d| < 0.2 small, 0.5 medium, > 0.8 large).",
f"These are descriptive, not inferential — with only {len(pre_years)} pre-2024 years and {len(post_years)} post-2024 years, statistical significance is not claimed.",
"",
"### Opposition-only right-wing motions",
"",
f"| Metric | Pre-2024 Mean | Post-2024 Mean | Δ | Cohen's d | N pre / N post |",
f"|--------|--------------|---------------|-----|-----------|---------------|",
f"| Centrist Support | {np.mean(opp_pre_cs):.3f} | {np.mean(opp_post_cs):.3f} | {np.mean(opp_post_cs) - np.mean(opp_pre_cs):+.3f} | {d_opp_cs:+.2f} | {len(opp_pre_cs)} / {len(opp_post_cs)} |",
f"| Pass Rate | {np.mean(opp_pre_pr):.3f} | {np.mean(opp_post_pr):.3f} | {np.mean(opp_post_pr) - np.mean(opp_pre_pr):+.3f} | {d_opp_pr:+.2f} | {len(opp_pre_pr)} / {len(opp_post_pr)} |",
f"| Extremity | {np.mean(opp_pre_ext):.2f} | {np.mean(opp_post_ext):.2f} | {np.mean(opp_post_ext) - np.mean(opp_pre_ext):+.2f} | {d_opp_ext:+.2f} | {len(opp_pre_ext)} / {len(opp_post_ext)} |",
"",
"**Interpretation gate:** If opposition metrics also rise post-2024, the shift is not",
"purely coalition-driven. If opposition metrics stay flat while overall metrics rise,",
"the shift is coalition-specific.",
"",
"## 3. Coalition Composition",
"",
COALITION_NOTE,
"",
"Submitter party is parsed from motion title prefixes",
"(e.g., \"Motie van het lid Wilders over ...\"). Only the lead submitter's party is",
"considered. Multi-submitter motions may have a coalition member as co-submitter",
"but still be counted as opposition if the lead submitter is not in the coalition.",
"",
"## 4. Domain Decomposition",
"",
"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 |",
"|--------|-----------------|------------------|------|-------------|-------------|------|",
]
for domain_name, domain_sum in [("Migration", mig_sum), ("Non-migration", non_mig_sum)]:
pre_cs = np.nanmean([_val(domain_sum, y, "mean_centrist_support") for y in pre_years])
post_cs = np.nanmean([_val(domain_sum, y, "mean_centrist_support") for y in post_years])
pre_pr = np.nanmean([_val(domain_sum, y, "pass_rate") for y in pre_years])
post_pr = np.nanmean([_val(domain_sum, y, "pass_rate") for y in post_years])
lines.append(
f"| {domain_name} | {pre_cs:.3f} | {post_cs:.3f} | {post_cs - pre_cs:+.3f} | "
f"{pre_pr:.3f} | {post_pr:.3f} | {post_pr - pre_pr:+.3f} |"
)
lines += [
"",
"## 5. Extremity-Stratified Pass Rate",
"",
ext_table,
"",
"**Key test:** If high-extremity motions (3–5) went from low pass rate to high pass rate",
"while mild motions stayed flat, centrists are more tolerant of extreme content —",
"direct Overton shift evidence. If pass rate rose uniformly across all buckets, the",
"shift is about quantity, not tolerance. If only the 1–2 bucket rose, right-wing",
"parties filed milder motions post-2024 and the 'shift' is illusory.",
"",
"## 6. Manual Extremity Audit",
"",
audit_notes,
"",
audit_table,
"",
"## 7. Limitations",
"",
"- **Small-N time series:** 8 pre-2024 years and at most 3 post-2024 years (2026 is partial).",
" Effect sizes are descriptive, not confirmatory.",
"- **LLM extremity scores:** Content-based, not independently validated beyond the",
" manual audit above. See §6 for agreement rate and noted biases.",
"- **Coalition composition:** Hardcoded per year. 2024 is ambiguous (Rutte IV until July,",
" Schoof thereafter). Early 2024 motions may be miscoded as Schoof-era.",
"- **Submitter party identification:** Parsed from motion title prefixes (e.g.,",
" 'Motie van het lid X'). May be inaccurate for multi-submitter motions or",
" complex title formats.",
"- **Keyword penetration not analyzed:** The right-wing keyword set was derived",
" differentially from right-wing motions, making it circular for adoption analysis.",
"- **Pass rate baseline:** Computed across all motions with voting data. Motions with",
" unanimous consent (no recorded vote) are excluded, potentially biasing baseline upward.",
"",
"## 8. Figures",
"",
f"![Figure 1: Centrist Support and Pass Rate]({Path(fig1_path).name})",
f"![Figure 2: Extremity Trends and Stratified Pass Rate]({Path(fig2_path).name})",
"",
"## 9. Conclusion",
"",
"*(Fill in after reviewing all indicators and audit results.)*",
]
report_path = REPORTS_DIR / "breakpoint_analysis.md"
with open(report_path, "w") as f:
f.write("\n".join(lines))
logger.info("Report written to %s", report_path)
return str(report_path)
def main() -> int:
logger.info("Connecting to database: %s", DB_PATH)
con = _conn(read_only=True)
logger.info("Computing yearly right-wing metrics...")
yearly_raw = compute_yearly_rw_metrics(con)
logger.info("Computing baseline (all motions) metrics...")
baseline_raw = compute_yearly_baseline(con)
logger.info("Building party name map from mp_metadata...")
name_party_map = build_party_name_map(con)
logger.info("Computing opposition-only metrics...")
opp_raw = compute_opposition_metrics(yearly_raw, name_party_map)
logger.info("Computing domain decomposition...")
mig_raw, non_mig_raw = compute_domain_metrics(yearly_raw)
logger.info("Computing extremity-stratified pass rates...")
ext_stratified = compute_extremity_stratified(yearly_raw)
con.close()
yearly_sum = yearly_summary(yearly_raw)
opp_sum = yearly_summary(opp_raw)
mig_sum = yearly_summary(mig_raw)
non_mig_sum = yearly_summary(non_mig_raw)
baseline_sum = yearly_summary(baseline_raw)
logger.info("Generating Figure 1...")
fig1_path = create_figure_1(yearly_sum, opp_sum, mig_sum, non_mig_sum, baseline_sum)
logger.info("Generating Figure 2...")
fig2_path = create_figure_2(yearly_sum, opp_sum, mig_sum, non_mig_sum, ext_stratified)
logger.info("Sampling motions for manual audit...")
audit_sample = sample_audit(yearly_raw)
print_audit(audit_sample)
logger.info("Generating report...")
audit_notes = (
"**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."
)
report_path = generate_report(
yearly_sum=yearly_sum,
opp_sum=opp_sum,
mig_sum=mig_sum,
non_mig_sum=non_mig_sum,
baseline_sum=baseline_sum,
ext_stratified=ext_stratified,
yearly_raw=yearly_raw,
opp_raw=opp_raw,
fig1_path=fig1_path,
fig2_path=fig2_path,
audit_sample=audit_sample,
audit_notes=audit_notes,
)
print(f"\nReport: {report_path}")
print(f"Figure 1: {fig1_path}")
print(f"Figure 2: {fig2_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

@ -0,0 +1,617 @@
#!/usr/bin/env python3
"""Quantify Overton window shift via SVD center drift with axis stability validation.
Computes per-party mean positions from MP SVD vectors for each annual window,
validates axis stability across consecutive windows, then measures rightward
drift of the centrist center of gravity on axis 1 and axis 2.
Usage:
uv run python analysis/right_wing/overton_svd_drift.py
"""
from __future__ import annotations
import json
import logging
import os
import sys
from collections import defaultdict
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
import duckdb
import matplotlib
import matplotlib.pyplot as plt
import numpy as np
from scipy.stats import spearmanr
matplotlib.use("Agg")
ROOT = Path(__file__).parent.parent.parent.resolve()
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from analysis.config import CANONICAL_RIGHT, PARTY_COLOURS, _PARTY_NORMALIZE
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger("overton_svd_drift")
CANONICAL_CENTRIST = frozenset({"VVD", "D66", "CDA", "NSC", "BBB", "ChristenUnie"})
DB_PATH = str(ROOT / "data" / "motions.db")
REPORTS_DIR = ROOT / "reports" / "overton_window"
STABILITY_THRESHOLD = 0.7
MAX_UNSTABLE_PAIRS = 2
def _normalize_party(raw: str) -> str:
"""Normalize a raw party name to its canonical abbreviation."""
return _PARTY_NORMALIZE.get(raw, raw)
def compute_party_positions(
con: duckdb.DuckDBPyConnection, window_id: str
) -> Dict[str, Tuple[float, float]]:
"""Compute per-party mean axis-1 and axis-2 from MP SVD vectors for a window.
Mirrors the logic of agent_tools/database.py:compute_party_positions_from_vectors.
"""
rows = con.execute(
"""
SELECT sv.entity_id, sv.vector, mm.party
FROM svd_vectors sv
JOIN mp_metadata mm ON sv.entity_id = mm.mp_name
WHERE sv.window_id = ? AND sv.entity_type = 'mp'
""",
(window_id,),
).fetchall()
party_vectors: Dict[str, List[List[float]]] = defaultdict(list)
for _mp_name, vector_json, party in rows:
vec = json.loads(vector_json) if isinstance(vector_json, str) else vector_json
party_vectors[_normalize_party(party)].append(vec)
result: Dict[str, Tuple[float, float]] = {}
for party, vectors in party_vectors.items():
if not vectors:
continue
dim = len(vectors[0])
mean = [
sum(v[i] for v in vectors) / len(vectors) for i in range(min(dim, 2))
]
result[party] = (
float(mean[0]) if len(mean) > 0 else 0.0,
float(mean[1]) if len(mean) > 1 else 0.0,
)
return result
def get_annual_windows(con: duckdb.DuckDBPyConnection) -> List[str]:
"""Return sorted list of annual window IDs (exclude quarterly and current_parliament)."""
rows = con.execute(
"""
SELECT DISTINCT window_id FROM svd_vectors
WHERE entity_type = 'mp'
AND window_id NOT LIKE '%-Q%'
AND window_id != 'current_parliament'
ORDER BY window_id
"""
).fetchall()
return [r[0] for r in rows]
def validate_axis_stability(
all_positions: Dict[str, Dict[str, Tuple[float, float]]],
windows: List[str],
) -> Tuple[bool, List[Dict[str, Any]], Dict[str, float]]:
"""Validate that SVD axes are stable enough for cross-window comparison.
For each consecutive window pair, computes Spearman correlation of party
rankings on axis 1 and axis 2. If either correlation < threshold, the pair
is flagged as unstable. If >2 unstable pairs, the comparison is aborted.
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_centers(
all_positions: Dict[str, Dict[str, Tuple[float, float]]],
windows: List[str],
) -> List[Dict[str, Any]]:
"""Compute centrist and right-wing centers of gravity per window.
Missing parties in a window are simply skipped (mean over available parties).
"""
results: List[Dict[str, Any]] = []
for window_id in windows:
pos = all_positions.get(window_id, {})
centrist_a1 = []
centrist_a2 = []
right_a1 = []
right_a2 = []
for party, (a1, a2) in pos.items():
if party in CANONICAL_CENTRIST:
centrist_a1.append(a1)
centrist_a2.append(a2)
if party in CANONICAL_RIGHT:
right_a1.append(a1)
right_a2.append(a2)
centrist_mean_a1 = float(np.mean(centrist_a1)) if centrist_a1 else None
centrist_mean_a2 = float(np.mean(centrist_a2)) if centrist_a2 else None
right_mean_a1 = float(np.mean(right_a1)) if right_a1 else None
right_mean_a2 = float(np.mean(right_a2)) if right_a2 else None
results.append({
"window_id": window_id,
"centrist_mean_axis1": centrist_mean_a1,
"centrist_mean_axis2": centrist_mean_a2,
"right_mean_axis1": right_mean_a1,
"right_mean_axis2": right_mean_a2,
"centrist_parties_present": sorted(
p for p in pos if p in CANONICAL_CENTRIST
),
"right_parties_present": sorted(
p for p in pos if p in CANONICAL_RIGHT
),
})
return results
def create_table(
con: duckdb.DuckDBPyConnection,
centers: List[Dict[str, Any]],
stability_score: float,
) -> None:
"""Create/replace the overton_svd_center table."""
con.execute("DROP TABLE IF EXISTS overton_svd_center")
con.execute("""
CREATE TABLE overton_svd_center (
window_id VARCHAR PRIMARY KEY,
centrist_mean_axis1 DOUBLE,
centrist_mean_axis2 DOUBLE,
right_mean_axis1 DOUBLE,
right_mean_axis2 DOUBLE,
stability_score DOUBLE
)
""")
for row in centers:
con.execute(
"""
INSERT INTO overton_svd_center
(window_id, centrist_mean_axis1, centrist_mean_axis2,
right_mean_axis1, right_mean_axis2, stability_score)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
row["window_id"],
row["centrist_mean_axis1"],
row["centrist_mean_axis2"],
row["right_mean_axis1"],
row["right_mean_axis2"],
stability_score,
),
)
def plot_trajectory(
centers: List[Dict[str, Any]],
stability_details: List[Dict[str, Any]],
avg_corrs: Dict[str, float],
output_path: str,
) -> None:
"""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]
cent_a1 = [c["centrist_mean_axis1"] for c in centers]
cent_a2 = [c["centrist_mean_axis2"] for c in centers]
right_a1 = [c["right_mean_axis1"] for c in centers]
right_a2 = [c["right_mean_axis2"] for c in centers]
valid_windows = [
windows[i]
for i in range(len(windows))
if cent_a1[i] is not None and cent_a2[i] is not None
]
if len(valid_windows) < 2:
ax.text(
0.5,
0.5,
"Insufficient data for trajectory plot",
transform=ax.transAxes,
ha="center",
va="center",
)
fig.savefig(output_path, dpi=150, bbox_inches="tight", facecolor="white")
plt.close(fig)
return
cent_a1_valid = [c for c in cent_a1 if c is not None]
cent_a2_valid = [c for c in cent_a2 if c is not None]
right_a1_valid = [c for c in right_a1 if c is not None]
right_a2_valid = [c for c in right_a2 if c is not None]
windows_valid = [w for w, a1 in zip(windows, cent_a1) if a1 is not None]
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)
if right_a1_valid and right_a2_valid:
ax.plot(right_a1_valid, right_a2_valid, "s--", color="#6A1B9A", linewidth=1.5,
markersize=6, label="Right-wing center (PVV, FVD, JA21, SGP)",
alpha=0.7, zorder=2)
for i, year in enumerate(years):
if i < len(cent_a1_valid) and cent_a1_valid[i] is not None:
ax.annotate(
str(year),
(cent_a1_valid[i], cent_a2_valid[i]),
textcoords="offset points",
xytext=(7, 7),
fontsize=8,
color="#333333",
)
ax.axhline(0, color="#CCCCCC", linewidth=0.5, linestyle="-")
ax.axvline(0, color="#CCCCCC", linewidth=0.5, linestyle="-")
ax.set_xlabel("SVD Axis 1")
ax.set_ylabel("SVD Axis 2")
ax.set_title(
f"Parliamentary Center Trajectory (2016–2026)\n"
f"Stability: axis1 ρ={avg_corrs.get('mean_axis1_corr', 0):.3f}, "
f"axis2 ρ={avg_corrs.get('mean_axis2_corr', 0):.3f}",
fontsize=11,
)
ax.legend(loc="upper left", fontsize=8, framealpha=0.9)
ax.set_aspect("equal", adjustable="datalim")
ax.grid(True, alpha=0.3)
fig.tight_layout()
fig.savefig(output_path, dpi=150, bbox_inches="tight", facecolor="white")
plt.close(fig)
logger.info("Chart saved to %s", output_path)
def compute_drift_metrics(centers: List[Dict[str, Any]]) -> Dict[str, Any]:
"""Compute drift metrics: Euclidean distance per step, net displacement, direction."""
valid = [c for c in centers if c["centrist_mean_axis1"] is not None]
if len(valid) < 2:
return {
"euclidean_steps": [],
"net_displacement": None,
"angular_direction_deg": None,
"rightward_distance_traveled": None,
}
euclidean_steps = []
for i in range(len(valid) - 1):
dx = valid[i + 1]["centrist_mean_axis1"] - valid[i]["centrist_mean_axis1"]
dy = valid[i + 1]["centrist_mean_axis2"] - valid[i]["centrist_mean_axis2"]
dist = float(np.sqrt(dx**2 + dy**2))
euclidean_steps.append({
"window_pair": f"{valid[i]['window_id']}-{valid[i+1]['window_id']}",
"distance": round(dist, 6),
"dx": round(dx, 6),
"dy": round(dy, 6),
})
first = valid[0]
last = valid[-1]
dx_net = last["centrist_mean_axis1"] - first["centrist_mean_axis1"]
dy_net = last["centrist_mean_axis2"] - first["centrist_mean_axis2"]
net_disp = float(np.sqrt(dx_net**2 + dy_net**2))
angle_rad = np.arctan2(dy_net, dx_net)
angle_deg = float(np.degrees(angle_rad))
return {
"euclidean_steps": euclidean_steps,
"net_displacement": round(net_disp, 6),
"net_dx": round(dx_net, 6),
"net_dy": round(dy_net, 6),
"angular_direction_deg": round(angle_deg, 2),
}
def write_report(
is_stable: bool,
stability_details: List[Dict[str, Any]],
avg_corrs: Dict[str, float],
centers: List[Dict[str, Any]],
drift: Dict[str, Any],
output_path: str,
chart_path: str,
) -> None:
"""Write the SVD stability and drift report as Markdown."""
lines: List[str] = []
lines.append("# SVD Center Drift & Axis Stability Report\n")
lines.append("## Axis Stability Validation\n")
lines.append(
f"**Stability threshold:** Spearman ρ ≥ {STABILITY_THRESHOLD} for both axes. "
f"Maximum unstable pairs allowed: {MAX_UNSTABLE_PAIRS}.\n"
)
unstable_count = sum(1 for d in stability_details if d.get("unstable"))
lines.append(
f"**Result:** {unstable_count} unstable pair(s) out of "
f"{len(stability_details)} consecutive window pairs.\n"
)
if not is_stable:
lines.append(
"**CONCLUSION: SVD axes are too unstable for longitudinal comparison. "
"Positions may reflect re-orientation rather than genuine drift. "
"The following drift metrics and chart should be interpreted with extreme caution.**\n"
)
lines.append(f"- Mean axis-1 correlation: {avg_corrs['mean_axis1_corr']:.4f}")
lines.append(f"- Mean axis-2 correlation: {avg_corrs['mean_axis2_corr']:.4f}\n")
lines.append("### Per-Pair Stability Details\n")
lines.append("| Window Pair | Axis 1 ρ | Axis 2 ρ | Unstable | Shared Parties |")
lines.append("|---|---|---|---|---|")
for d in stability_details:
r1 = f"{d['axis1_corr']:.3f}" if d["axis1_corr"] is not None else "N/A"
r2 = f"{d['axis2_corr']:.3f}" if d["axis2_corr"] is not None else "N/A"
flag = "**YES**" if d.get("unstable") else "no"
parties = ", ".join(d.get("shared_parties", []))
lines.append(f"| {d['window_pair']} | {r1} | {r2} | {flag} | {parties} |")
lines.append("")
lines.append("## Centrist Center of Gravity\n")
lines.append(
"| Window | Centrist Ax1 | Centrist Ax2 | Right Ax1 | Right Ax2 | "
"Centrist Parties Present | Right Parties Present |"
)
lines.append("|---|---|---|---|---|---|---|")
for c in centers:
cent_a1 = f"{c['centrist_mean_axis1']:.4f}" if c["centrist_mean_axis1"] is not None else "N/A"
cent_a2 = f"{c['centrist_mean_axis2']:.4f}" if c["centrist_mean_axis2"] is not None else "N/A"
right_a1 = f"{c['right_mean_axis1']:.4f}" if c["right_mean_axis1"] is not None else "N/A"
right_a2 = f"{c['right_mean_axis2']:.4f}" if c["right_mean_axis2"] is not None else "N/A"
cent_parties = ", ".join(c["centrist_parties_present"])
right_parties = ", ".join(c["right_parties_present"])
lines.append(
f"| {c['window_id']} | {cent_a1} | {cent_a2} | {right_a1} | {right_a2} "
f"| {cent_parties} | {right_parties} |"
)
lines.append("")
if is_stable:
lines.append("## Drift Metrics\n")
lines.append(f"- **Net displacement (first → last):** {drift['net_displacement']}")
lines.append(f" - Δ axis-1: {drift['net_dx']}")
lines.append(f" - Δ axis-2: {drift['net_dy']}")
lines.append(f"- **Net direction:** {drift['angular_direction_deg']}° "
f"(arctan2(Δy, Δx))")
lines.append(f" - Positive Δx = rightward on axis 1")
lines.append(f" - Positive Δy = upward on axis 2\n")
lines.append("### Year-over-Year Drift\n")
lines.append("| Window Pair | Euclidean Distance | Δ Axis-1 | Δ Axis-2 |")
lines.append("|---|---|---|---|")
total_dist = 0.0
for step in drift["euclidean_steps"]:
lines.append(
f"| {step['window_pair']} | {step['distance']:.6f} "
f"| {step['dx']:+.6f} | {step['dy']:+.6f} |"
)
total_dist += step["distance"]
lines.append(f"\n**Total path length:** {total_dist:.6f}\n")
else:
lines.append("## Drift Metrics (UNRELIABLE — Axes Unstable)\n")
lines.append(
"Drift metrics were computed but are unreliable due to axis instability. "
"Cross-window comparisons on unstable axes conflate positional change "
"with axis re-orientation.\n"
)
lines.append(f"## Chart\n")
lines.append(f"![SVD Drift Chart]({os.path.basename(chart_path)})\n")
lines.append("## Interpretability Statement\n")
if is_stable:
lines.append(
"The SVD axes show sufficient stability for cross-window comparison. "
"The parliamentary center trajectory reflects genuine shifts in voting "
"behavior rather than axis re-orientation artifact. The centrist center-of-gravity "
"movement on the 2D compass can be interpreted as a measure of ideological drift.\n"
)
else:
lines.append(
"SVD axes are too unstable for longitudinal comparison. The trajectory "
"plotted above may reflect axis re-orientation (each SVD window independently "
"determines its principal axes) rather than genuine ideological drift. "
"We recommend against drawing conclusions from this analysis.\n"
)
lines.append("---\n")
lines.append(
"*Note: SVD axes reflect voting patterns, not semantic content. "
"A shift means voting behavior changed, not that parties changed their rhetoric. "
"See: docs/solutions/best-practices/svd-labels-voting-patterns-not-semantics.md*\n"
)
os.makedirs(os.path.dirname(output_path), exist_ok=True)
with open(output_path, "w", encoding="utf-8") as f:
f.write("\n".join(lines) + "\n")
logger.info("Report saved to %s", output_path)
def main() -> None:
os.makedirs(str(REPORTS_DIR), exist_ok=True)
con = duckdb.connect(database=DB_PATH, read_only=False)
try:
windows = get_annual_windows(con)
logger.info("Found %d annual windows: %s", len(windows), windows)
all_positions: Dict[str, Dict[str, Tuple[float, float]]] = {}
for w in windows:
pos = compute_party_positions(con, w)
all_positions[w] = pos
n_parties = len(pos)
centrist_present = sum(1 for p in pos if p in CANONICAL_CENTRIST)
right_present = sum(1 for p in pos if p in CANONICAL_RIGHT)
logger.info(
"Window %s: %d parties, %d centrist, %d right",
w, n_parties, centrist_present, right_present,
)
is_stable, stability_details, avg_corrs = validate_axis_stability(
all_positions, windows
)
unstable_count = sum(1 for d in stability_details if d.get("unstable"))
logger.info(
"Stability: %s (%d/%d unstable pairs), mean axis1 ρ=%.3f, mean axis2 ρ=%.3f",
"STABLE" if is_stable else "UNSTABLE",
unstable_count,
len(stability_details),
avg_corrs["mean_axis1_corr"],
avg_corrs["mean_axis2_corr"],
)
for d in stability_details:
if d.get("unstable"):
logger.warning(
"Unstable pair %s: axis1=%.3f, axis2=%.3f, reason=%s",
d["window_pair"],
d["axis1_corr"] or 0,
d["axis2_corr"] or 0,
d.get("reason", ""),
)
centers = compute_centers(all_positions, windows)
stability_score = (
avg_corrs["mean_axis1_corr"] + avg_corrs["mean_axis2_corr"]
) / 2.0
for c_row in centers:
c_row["stability_score"] = stability_score
create_table(con, centers, stability_score)
n_rows = con.execute("SELECT COUNT(*) FROM overton_svd_center").fetchone()[0]
logger.info("Created overton_svd_center table with %d rows", n_rows)
chart_path = str(REPORTS_DIR / "svd_drift_chart.png")
plot_trajectory(centers, stability_details, avg_corrs, chart_path)
drift = compute_drift_metrics(centers)
report_path = str(REPORTS_DIR / "svd_stability_report.md")
write_report(
is_stable, stability_details, avg_corrs, centers,
drift, report_path, chart_path,
)
summary = {
"stability_status": "STABLE" if is_stable else "UNSTABLE",
"unstable_pairs": unstable_count,
"total_pairs": len(stability_details),
"mean_axis1_corr": round(avg_corrs["mean_axis1_corr"], 4),
"mean_axis2_corr": round(avg_corrs["mean_axis2_corr"], 4),
"windows": len(windows),
"table_rows": n_rows,
"net_displacement": drift.get("net_displacement"),
"net_dx": drift.get("net_dx"),
"net_dy": drift.get("net_dy"),
"angular_direction_deg": drift.get("angular_direction_deg"),
}
logger.info("Summary: %s", json.dumps(summary, indent=2))
return summary
finally:
con.close()
if __name__ == "__main__":
result = main()
print(json.dumps(result, indent=2))

@ -0,0 +1,252 @@
---
title: Visualize and Report Migration-Anti-Democratic Overlap Findings
type: feat
status: active
date: 2026-05-08
---
# Visualize and Report Migration-Anti-Democratic Overlap Findings
## Summary
Turn the Direction 3 analysis (migration as the dominant vehicle for anti-democratic rhetoric in right-wing motions) into a publishable artifact: committed script, visualizations, mechanism-coded extreme motions, and a findings report.
---
## Problem Frame
The Direction 3 analysis script `analysis/right_wing/direction3_migration_antidemocratic.py` has been written and run successfully against all 2,986 scored right-wing motions. It uncovered strong evidence that migration is the primary carrier of anti-democratic extremity (45% of ≥4.0-scored motions are migration-related). However, the findings currently exist only as console output. They need to be preserved, visualized, deepened with mechanism coding, and compiled into a shareable report.
---
## Requirements
- R1. The analysis script is committed to the feature branch.
- R2. Five focused charts visualize the key findings for immediate readability.
- R3. The 212 motions scoring ≥3.5 extremity are mechanism-coded via lightweight LLM batch.
- R4. A markdown report compiles numbers, charts, and mechanism coding into a narrative.
- R5. All artifacts are saved under `reports/right_wing_migration_antidemocratic/`.
---
## Scope Boundaries
- No new LLM pipeline infrastructure (reuses existing `ai_provider.chat_completion_json_parallel` pattern from `extremity_scorer.py`).
- No changes to the database schema.
- No Streamlit UI integration (this is a research artifact, not a UI feature).
- No anti-democratic scoring pipeline for the full 2,986 motions (that would be Direction 2, deferred).
### Deferred to Follow-Up Work
- Full anti-democratic scoring pipeline (Direction 2): separate plan if mechanism coding validates the concept.
- Streamlit tab to expose right-wing analysis tables: separate UI work.
---
## Context & Research
### Relevant Code and Patterns
- `analysis/right_wing/direction3_migration_antidemocratic.py` — the analysis script to commit
- `analysis/right_wing/extremity_scorer.py` — LLM batch pattern with `chat_completion_json_parallel`
- `analysis/right_wing/derive_categories.py` — two-phase LLM approach (derive taxonomy, then apply)
- `scripts/motion_drift.py` — chart generation pattern using matplotlib, saved to `reports/`
- `analysis/config.py``CANONICAL_RIGHT`, `CANONICAL_LEFT`, `PARTY_COLOURS`
### Institutional Learnings
- `docs/solutions/best-practices/svd-labels-voting-patterns-not-semantics.md` — SVD labels should reflect voting patterns, not semantic content. Same discipline applies here: mechanism coding should reflect procedural/institutional characteristics, not just topic keywords.
---
## Key Technical Decisions
- **Matplotlib for charts:** The repo already uses matplotlib in `scripts/motion_drift.py` and `explorer.py`. No new charting library.
- **Reuse existing LLM batch infrastructure:** The mechanism coder will call `ai_provider.chat_completion_json_parallel` with a JSON schema, same pattern as extremity/sentiment scoring. Batch size 10, ~22 batches for 212 motions.
- **Mechanism taxonomy (6 classes):** punitive, exclusionary, sovereignty-claiming, procedural-breaking, institutional-dismantling, none. Chosen to capture the observed framing evolution from "keep them out" to "dismantle the system that lets them in."
- **Reports directory:** `reports/right_wing_migration_antidemocratic/` mirrors the `reports/drift/` pattern.
---
## Open Questions
### Resolved During Planning
- **Q: Which visualization library?** Matplotlib — already used in the repo.
- **Q: How many mechanisms to code?** 6-class taxonomy, validated against the 5.0-scored motion sample.
### Deferred to Implementation
- **Q: Exact chart styling and color palette** — depends on what looks good when rendered; iterate visually.
- **Q: Whether to include migration-adjacent motions in the mechanism coding** — start with pure migration (404), expand to adjacent (55) if time permits.
---
## Implementation Units
- U1. **Commit Direction 3 Analysis Script**
**Goal:** Save the working analysis script to the feature branch.
**Requirements:** R1
**Dependencies:** None
**Files:**
- Create: `analysis/right_wing/direction3_migration_antidemocratic.py`
**Approach:**
- The script already exists and produces correct output. Stage and commit it with a conventional message.
**Test scenarios:**
- Test expectation: none — this is a standalone analysis script with no testable behavior changes.
**Verification:**
- Script is committed to `feat/right-wing-motion-analysis` and runs without errors.
---
- U2. **Generate Five Key Visualizations**
**Goal:** Produce charts that make the Direction 3 findings immediately readable.
**Requirements:** R2, R5
**Dependencies:** U1
**Files:**
- Create: `analysis/right_wing/visualize_direction3.py`
- Output: `reports/right_wing_migration_antidemocratic/*.png`
**Approach:**
- Write a script that queries the scored data and generates 5 charts:
1. **Stacked bar:** Category breakdown of ≥4.0 extremity motions (migration's 44.8% dominance)
2. **Line chart:** Migration motion volume + avg extremity by year (2018–2026)
3. **Horizontal bar:** Party avg extremity on migration (Wilders 3.79, Eerdmans 2.69, etc.)
4. **Grouped bar:** Migration sentiment by extremity bucket (the −0.717 drop)
5. **Treemap or stacked bar:** Migration + migration-adjacent by category (15.4% total footprint)
- Follow the matplotlib patterns from `scripts/motion_drift.py` and `explorer.py`.
- Save charts to `reports/right_wing_migration_antidemocratic/`.
**Patterns to follow:**
- `scripts/motion_drift.py` — chart saving with `plt.savefig()`, DPI settings
- `explorer.py` — color palette using `analysis/config.PARTY_COLOURS`
**Test scenarios:**
- Happy path: script runs and produces 5 PNG files in the reports directory.
- Edge case: empty result set for a chart component should render gracefully (e.g., skip treemap segment with zero count).
**Verification:**
- 5 PNG files exist in `reports/right_wing_migration_antidemocratic/` and render legible charts.
---
- U3. **Mechanism-Code the 212 Extreme Motions**
**Goal:** Classify the anti-democratic mechanism for each motion scoring ≥3.5 extremity.
**Requirements:** R3
**Dependencies:** U1
**Files:**
- Create: `analysis/right_wing/mechanism_coder.py`
- Create: `data/mechanism_codes.json` (or table: `right_wing_mechanisms`)
**Approach:**
- Query the 212 motion IDs with title, year, and category from `right_wing_motions` + `extremity_scores`.
- Use `ai_provider.chat_completion_json_parallel` with a JSON schema:
```json
{"mechanism": "punitive|exclusionary|sovereignty-claiming|procedural-breaking|institutional-dismantling|none", "confidence": 1-5, "rationale": "string"}
```
- Batch size 10, ~22 batches. Skip already-coded motions for resumability.
- Store results in a new table `right_wing_mechanisms` (motion_id, mechanism, confidence, rationale) with `CREATE TABLE IF NOT EXISTS` + `INSERT OR REPLACE`.
**Execution note:** Start with a small validation batch (10 motions) and spot-check the taxonomy before running the full 212.
**Patterns to follow:**
- `analysis/right_wing/extremity_scorer.py` — batch loop, `chat_completion_json_parallel`, resumability pattern
- `analysis/right_wing/derive_categories.py` — two-phase validation (small sample first, then apply)
**Test scenarios:**
- Happy path: all 212 motions are coded with a valid mechanism and confidence 1–5.
- Edge case: LLM returns invalid JSON → retry or log error, don't crash.
- Edge case: script interrupted mid-batch → rerunning skips already-coded motions.
- Integration: mechanism table can be joined back to `right_wing_motions` for reporting.
**Verification:**
- `right_wing_mechanisms` table has 212 rows with no NULL mechanisms.
- Spot-check 10 random rows: rationale is coherent and mechanism assignment is defensible.
---
- U4. **Compile Findings Report**
**Goal:** Write a markdown narrative pulling together numbers, charts, and mechanism coding.
**Requirements:** R4, R5
**Dependencies:** U2, U3
**Files:**
- Create: `reports/right_wing_migration_antidemocratic/findings_report.md`
**Approach:**
- Structure the report around the 5 analytical sections from Direction 3:
1. Overlap quantification (migration's 44.8% share of high extremity)
2. Party strategy (PVV/Wilders dominance, JA21 volume-vs-intensity tradeoff)
3. Framing shift (2018–2020 direct exclusion → 2023–2025 institutional dismantling)
4. Cross-category adjacency (55 migration-adjacent motions, veiligheid/justitie as primary spillover)
5. Sentiment divergence (migration as the only negative-sentiment category, −0.717 at high extremity)
- Embed the 5 charts using relative paths.
- Include a mechanism-coding summary table (distribution of the 6 mechanisms across categories and parties).
- Conclude with the bottom-line hypothesis confirmation.
**Patterns to follow:**
- `reports/drift/report.md` — existing report format in the repo
**Test scenarios:**
- Happy path: report renders correctly in a markdown viewer with all images loading.
- Edge case: report references charts that don't exist → verify all image paths are valid.
**Verification:**
- `reports/right_wing_migration_antidemocratic/findings_report.md` exists and is a complete, readable narrative.
- All chart references resolve to existing PNG files.
---
## System-Wide Impact
- **Interaction graph:** None — this is a research artifact pipeline. No callbacks, middleware, or UI changes.
- **Error propagation:** LLM batch failures in U3 should log errors per-batch, not crash the script.
- **State lifecycle risks:** The `right_wing_mechanisms` table is idempotent (INSERT OR REPLACE). Rerunning is safe.
- **API surface parity:** No public API changes.
- **Integration coverage:** U3's integration with the LLM provider is the only cross-layer concern; verify the batch loop handles rate limits gracefully.
- **Unchanged invariants:** Database schema, existing right-wing analysis tables, Streamlit UI, and agent_tools surface remain untouched.
---
## Risks & Dependencies
| Risk | Mitigation |
|------|------------|
| LLM API costs for 212 motions exceed budget | Validate on 10-motion sample first; abort if cost-per-motion is unexpectedly high. |
| Mechanism taxonomy doesn't map cleanly to the data | Start with 6 classes; if LLM struggles, collapse to 4 or add an "other" catch-all. |
| Charts look unprofessional or cluttered | Iterate on 1–2 charts first, get feedback, then apply style to all 5. |
| Report becomes too long or unfocused | Cap at ~2,000 words; use summary tables and embedded charts to carry the narrative. |
---
## Documentation / Operational Notes
- The report is intended for internal research consumption and potential external sharing. No operational rollout needed.
- If the mechanism coding validates the anti-democratic concept, it becomes the evidence base for a future Direction 2 plan (full anti-democratic scoring pipeline).
---
## Sources & References
- Origin analysis: `analysis/right_wing/direction3_migration_antidemocratic.py`
- Related plan: `docs/plans/2026-05-05-001-feat-right-wing-motion-analysis-plan.md`
- Chart patterns: `scripts/motion_drift.py`, `explorer.py`
- LLM batch patterns: `analysis/right_wing/extremity_scorer.py`, `analysis/right_wing/derive_categories.py`

@ -0,0 +1,367 @@
---
title: Quantify the Overton Window Shift in Dutch Parliament
type: feat
status: active
date: 2026-05-08
---
> **Supersedes:** `docs/plans/2026-05-08-001-feat-visualize-migration-antidemocratic-plan.md`
# Quantify the Overton Window Shift in Dutch Parliament
## Summary
Test the hypothesis that the Overton window has shifted rightward in the Tweede Kamer by analyzing three objective indicators: (1) centrist support for and passage of right-wing motions, with a focus on opposition motions to control for coalition effects, (2) whether content extremity of proposed motions increased over time, and (3) rightward drift of the parliamentary center via SVD positions. The analysis is deliberately descriptive — we report effect sizes and visual patterns, not statistical confirmation from an 8-point time series.
---
## Problem Frame
Direction 3 analysis revealed a dramatic structural break in 2024: centrist support for right-wing motions nearly doubled (0.33 → 0.67), and pass rates jumped from ~33% to ~70%. This is consistent with an Overton window shift — but it could also be a coalition artifact (NSC/VVD supporting PVV government proposals) rather than a genuine ideological repositioning. We need rigorous, multi-indicator evidence to distinguish these explanations.
The existing mechanism-coding plan answers "what kinds of anti-democratic mechanisms exist in extreme motions" — a different question. This plan answers the user's actual question: **"Has the Overton window shifted, and what objective indications do we have?"**
---
## Requirements
- R1. Quantify the 2024 breakpoint in centrist support and pass rates with effect sizes, not overclaimed statistical significance.
- R2. Test whether the shift persists when controlling for coalition status — i.e., do opposition right-wing motions also gain centrist support post-2024?
- R3. Test whether the shift is domain-specific to migration or broader across right-wing motions.
- R4. Quantify whether the content extremity of proposed right-wing motions increased over time.
- R5. Quantify whether the parliamentary center shifted rightward on SVD axes, with axis stability validated before cross-window comparison.
- R6. A concise report with 3 charts and an honest, uncertainty-aware conclusion.
---
## Scope Boundaries
- In scope: Descriptive quantitative analysis of vote data, SVD positions, content extremity trends, and coalition status.
- Out of scope: LLM-based mechanism coding, sentiment analysis refinements, keyword penetration analysis (see Key Technical Decisions), anti-democratic scoring pipeline.
- No Streamlit UI changes — this is a research artifact.
- No changes to the classification pipeline or agent_tools.
### Deferred to Follow-Up Work
- Full anti-democratic scoring pipeline (Direction 2)
- Interactive dashboard for Overton window monitoring
- Causal inference modeling (diff-in-diff, regression discontinuity) — requires more data or a valid control group
- Longitudinal keyword adoption study with independently-derived keyword sets (not the differential TF-IDF set from right-wing motions)
- **Two-dimensional extremity validation:** Score a stratified sample (50–100 motions) for both *stylistic extremity* (inflammatory language, emotional charge) and *material impact* (rights restricted, groups affected, enforcement mechanisms). If correlation is low (r < 0.6), re-score all 2,986 motions with a refined dual-dimension prompt so extremity-stratified analyses can separate "harsh phrasing" from "substantive policy impact." If correlation is high (r > 0.7), the single score is sufficient.
---
## Context & Research
### Relevant Code and Patterns
- `analysis/right_wing/direction3_migration_antidemocratic.py` — Direction 3 analysis script
- `analysis/explorer_data.py``load_party_scores_all_windows()`, `get_available_windows()`, SVD position computation
- `analysis/right_wing/classify_motions.py` — keyword matching logic for right-wing classification
- `analysis/config.py``CANONICAL_RIGHT`, `CANONICAL_LEFT`, `CANONICAL_CENTRIST`, `PARTY_COLOURS`
- `scripts/motion_drift.py` — matplotlib chart patterns
### Data Assets
- `right_wing_motions` — classified motions with `centrist_support`, `right_support`, `left_opposition`
- `extremity_scores` — LLM-scored extremity per motion
- `mp_votes` — individual MP votes per motion (for pass/fail and party breakdown)
- `motions` — motion metadata including `date`, `title`, `voting_results` (JSON)
- `svd_vectors` — SVD embeddings per MP/party per window
- `party_results` — session-level party agreement data
### Institutional Learnings
- `docs/solutions/best-practices/svd-labels-voting-patterns-not-semantics.md` — SVD positions reflect voting patterns, not semantic content. This is a feature: if the center shifts on SVD axes, it's because voting behavior changed, not because words changed.
---
## Key Technical Decisions
- **No LLM batching:** All indicators are computable from existing structured data. This keeps the analysis deterministic, cheap, and reproducible.
- **Descriptive statistics only:** With only 8–11 annual observations (2016–2026), statistical tests like Mann-Kendall are underpowered. We report Cohen's d, pre/post differences, and visual patterns — not p-values claimed as "confirmation."
- **Opposition-only filter as coalition control:** Instead of comparing government vs. opposition (which conflates the 2024 coalition change with the Overton shift), we compare opposition right-wing motions pre- and post-2024. If opposition motions also gain centrist support, the shift is not purely coalition-driven.
- **Keyword penetration analysis DROPPED:** The right-wing keywords were derived differentially from right-wing motion titles. Finding these keywords in centrist motions does not demonstrate ideological adoption — it demonstrates topic salience. This would require an independently-derived keyword set (e.g., from party manifestos or media corpora). Deferred.
- **SVD cross-window comparison requires stability validation:** Axes are re-oriented per window (see `political_axis.py`). Before comparing centrist positions across years, we validate that the same parties occupy roughly the same relative positions. If instability exceeds a threshold, we report the drift as uninterpretable.
- **Coalition status inferred from submitter party:** Use motion title prefix to identify lead MP, map to party via `mp_metadata`, then tag as "government" if the party is in the governing coalition for that year. Coalition composition is hardcoded per year.
---
## Open Questions
### Resolved During Planning
- **Q: How to define "centrist"?** Use `CANONICAL_CENTRIST = frozenset({"VVD", "D66", "CDA", "NSC", "BBB", "CU"})` from `classify_motions.py`.
- **Q: How to define "government" vs. "opposition"?** Hardcode coalition composition per year based on historical Dutch governments.
- **Q: Why drop keyword penetration?** Circular: keywords were derived from right-wing motions; their appearance in centrist motions measures topic prevalence, not ideological adoption.
- **Q: What statistical test?** None. With 8–11 points, we report descriptive effect sizes (Cohen's d) and let charts carry the evidence.
### Deferred to Implementation
- **Q: Exact coalition composition for 2016–2023** — verify against Wikipedia or Binnenhof records at implementation time.
- **Q: Whether to use quarterly or annual SVD windows** — start with annual for simplicity; quarterly if annual resolution is too coarse.
- **Q: Threshold for SVD axis stability** — define at implementation (e.g., Spearman correlation of party rankings across windows ≥ 0.8).
---
## Implementation Units
### U1. Commit Direction 3 Script
**Goal:** Preserve the working Direction 3 analysis.
**Requirements:** R1 (context)
**Dependencies:** None
**Files:**
- Stage/Commit: `analysis/right_wing/direction3_migration_antidemocratic.py`
**Approach:** Stage and commit the existing script with a conventional message.
**Verification:** Script runs without errors.
---
### U2. Descriptive Breakpoint & Opposition-Controlled Analysis
**Goal:** Quantify the 2024 breakpoint in centrist support, pass rates, and content extremity — with coalition effects controlled via opposition-only filtering, domain decomposition, and a baseline comparison.
**Requirements:** R1, R2, R3, R4
**Dependencies:** U1
**Files:**
- Create: `analysis/right_wing/overton_breakpoint_analysis.py`
- Output: `reports/overton_window/breakpoint_analysis.md`
- Output: `reports/overton_window/breakpoint_figure_1.png` (centrist support + pass rate)
- Output: `reports/overton_window/breakpoint_figure_2.png` (extremity + motion count)
**Prerequisites:**
- `right_wing_motions` table must have `category` column (run `derive_categories.py` if missing).
- `reports/overton_window/` directory must exist (create if missing).
**Approach:**
1. **Yearly aggregates (all right-wing motions):**
- Mean `centrist_support`, mean `right_support`, mean `left_opposition`
- Pass rate (computed from `mp_votes` or `voting_results`)
- Mean `extremity_score` (from `extremity_scores`)
- Count of motions
2. **Baseline comparison (all motions):**
- For each year, compute pass rate across **all** motions (not just right-wing classified ones)
- For each year, compute mean centrist support across **all** motions (% of centrist parties voting "voor")
- These serve as dashed reference lines to test whether the 2024 shift is specific to right-wing content or a general coalition effect
3. **Pre/post 2024 comparison:**
- Compute difference-in-means for centrist support, pass rate, and extremity
- Report Cohen's d as effect size
- Do NOT claim statistical significance — frame as "effect size" only
4. **Opposition-only analysis (coalition control):**
- Hardcode coalition composition per year (see Key Technical Decisions)
- For each right-wing motion, identify the lead submitter party from the title prefix (or `mp_votes` majority submitter)
- Filter to motions where the submitter party is NOT in the ruling coalition
- Recompute yearly centrist support and pass rate for this opposition-only subset
- Compare pre-2024 vs post-2024 opposition metrics
- **Interpretation gate:** If opposition metrics also rise post-2024, the shift is not purely coalition-driven. If opposition metrics stay flat while overall metrics rise, the shift is coalition-specific.
5. **Domain decomposition (migration vs. non-migration):**
- Split right-wing motions into migration (`category = 'asiel/vreemdelingen'`) and non-migration
- Compute yearly centrist support, pass rate, and extremity for each
- Test: is the 2024 shift driven entirely by migration, or is it broader?
6. **Extremity-stratified pass rate (tolerance shift test):**
- Bucket right-wing motions by extremity score: 1–2 (mild), 2–3 (moderate), 3–4 (high), 4–5 (extreme)
- Compute pass rate per bucket for pre-2024 (2018–2023) and post-2024 (2024–2025)
- **Key test:** If high-extremity motions (3–5) went from low pass rate → high pass rate while mild motions stayed flat, centrists are more tolerant of extreme content — direct Overton shift evidence
- If pass rate rose uniformly across all buckets, the shift is about quantity, not tolerance
- If only the 1–2 bucket rose, right-wing parties filed milder motions post-2024 and the "shift" is illusory
7. **Visualization (two figures):**
- **Figure 1** (2 panels) — core evidence:
- Panel A: Centrist support over time (all right-wing, opposition-only, migration, non-migration) + dashed baseline (all motions)
- Panel B: Pass rate over time (same breakdown) + dashed baseline (all motions)
- **Figure 2** (2 panels) — supplementary:
- Panel C: Mean extremity over time (all right-wing, opposition-only, migration, non-migration)
- Panel D: Extremity-stratified pass rate — grouped bars showing pass rate per extremity bucket for pre-2024 vs post-2024, with N labels per bar
- Annotate 2024 with a vertical line on panels A–C
8. **Manual extremity audit (LLM score validation):**
- After computing results, sample 5 motions per extremity bucket (20 total) using stratified random sampling
- For each sampled motion, display: title, category, LLM extremity score, and a short excerpt from the body text (first 300 chars)
- As the analyst, read each motion and judge: "Would I assign this to the same extremity bucket (1–2 / 2–3 / 3–4 / 4–5)?" Record agreement per motion
- **Language impact divergence check:** For each motion, note whether the LLM score appears driven by *stylistic extremity* (inflammatory phrasing) or *material impact* (substantive rights restriction, institutional change). Flag motions where the two diverge (e.g., mild language but severe policy impact, or inflammatory language but trivial mechanism)
- Report overall agreement rate and note any systematic LLM bias (e.g., "LLM overrates anti-institutional language" or "LLM underrates economic nationalism")
- If agreement < 70%, flag LLM scoring as unreliable for the stratified analysis and note in the report; still present results but with stronger caveats
9. **Report:** One markdown document with sections for each analysis, the manual audit table, embedded charts, and an honest uncertainty statement. Include N per extremity bucket so reader can gauge reliability.
**Patterns to follow:**
- `scripts/motion_drift.py` — matplotlib multi-panel chart saving
- `analysis/right_wing/temporal_analysis.py` — yearly aggregation patterns
- `analysis/right_wing/classify_motions.py` — title parsing for MP names and party mapping
**Test scenarios:**
- Happy path: script produces a markdown report with all analyses, manual audit table, and two charts.
- Edge case: missing data for a year → skip that year with a warning.
- Edge case: no opposition motions in a year → report NaN with a note.
- Edge case: coalition composition ambiguous for a year → document assumption and continue.
- Edge case: parsed submitter party has 0 "voor" votes in `voting_results` → flag as anomalous and skip for coalition tagging.
- Edge case: <5 motions in an extremity bucket sample what's available and note reduced sample size.
- Edge case: manual audit agreement < 70% flag LLM scoring reliability in the report.
**Verification:**
- Report contains pre/post difference, Cohen's d, opposition-only comparison, baseline comparison, and extremity-stratified pass rates.
- Manual audit table shows agreement rate ≥ 70% (or unreliable flag set).
- Charts clearly show the 2024 break, the baseline reference, and pre/post pass rates by extremity bucket.
- Report includes an explicit statement about statistical limitations (small-N time series).
---
### U3. SVD Center Drift with Stability Validation
**Goal:** Quantify whether the parliamentary center shifted rightward on the political compass, but only if axes are stable enough for cross-window comparison.
**Requirements:** R5
**Dependencies:** U2
**Files:**
- Create: `analysis/right_wing/overton_svd_drift.py`
- Create table: `overton_svd_center` (window_id, centrist_mean_axis1, centrist_mean_axis2, right_mean_axis1, right_mean_axis2, stability_score)
- Output: `reports/overton_window/svd_drift_chart.png`
- Output: `reports/overton_window/svd_stability_report.md`
**Approach:**
1. **Load SVD vectors** from `svd_vectors` table for all annual windows.
2. **Compute mean position per party per window** using `compute_party_positions_from_vectors` logic.
3. **Axis stability validation (GATE):**
- For each pair of consecutive windows, compute Spearman correlation of party rankings on axis 1 and axis 2
- If correlation < 0.7 for either axis, flag the window pair as unstable
- If >2 unstable pairs exist, ABORT the cross-window comparison and report: "SVD axes are too unstable for longitudinal comparison. Positions may reflect re-orientation rather than genuine drift."
- If stable, proceed.
4. **Calculate the "center of gravity":**
- Mean of centrist parties' axis-1 and axis-2 positions per window
- Also compute mean of right-wing parties' positions as a reference point
- Store in `overton_svd_center`
5. **Plot:**
- Centrist center trajectory over time on the 2D compass
- Right-wing center trajectory as a dashed reference line
- Annual points labeled with year
6. **Compute drift metrics:**
- Euclidean distance between consecutive annual centrist centers (drift speed)
- Net displacement from first to last window
- Angular direction of drift (is it toward the right-wing cluster?)
7. **Report:** Markdown with stability validation results, drift metrics, and a clear statement about whether the drift is interpretable.
**Patterns to follow:**
- `analysis/explorer_data.py``load_party_scores_all_windows_aligned()`, `compute_party_positions_from_vectors`
- `explorer.py` — compass plotting with `PARTY_COLOURS`
**Test scenarios:**
- Happy path: table has one row per annual window with non-null centrist means and stability_score.
- Edge case: missing party in a window → compute mean over available centrist parties.
- Edge case: axis instability detected → script reports instability and skips drift interpretation.
**Verification:**
- `overton_svd_center` table has ≥8 rows (one per annual window).
- Stability validation is documented in the report.
- If stable, chart shows a clear centrist trajectory; 2024 position is visually rightward of 2020–2023.
- If unstable, report explicitly states that cross-window comparison is uninterpretable.
---
### U4. Compile Findings Report
**Goal:** Synthesize all indicators into a concise, publishable narrative with honest uncertainty framing.
**Requirements:** R6
**Dependencies:** U2, U3
**Files:**
- Create: `reports/overton_window/findings_report.md`
**Approach:**
1. Structure the report around the three indicators:
1. **Breakpoint & Coalition Control:** Did centrist support and pass rates break in 2024? Does the effect persist for opposition motions? (U2)
2. **Content Extremity Trend:** Did the extremity of proposed motions increase over time? How valid are the LLM scores? (U2 audit)
3. **Spatial Drift:** Did the parliamentary center move right on SVD axes? (U3)
4. **Domain Specificity:** Is the shift migration-specific or broader? (U2)
5. **Tolerance Shift:** Did centrists become more accepting of high-extremity content specifically? (U2 extremity-stratified)
2. Include embedded charts from U2 (two figures) and U3.
3. **Conclusion with uncertainty hierarchy:**
- Strong evidence: multiple indicators converge (e.g., opposition support rises + high-extremity pass rate rises + SVD drifts right + content extremity increases)
- Mixed evidence: some indicators shift, others don't
- Weak/unclear evidence: indicators conflict or data is too sparse
- Explicitly state that the Overton window is a theoretical construct; we measure observable correlates, not the construct itself.
4. Cap at ~1,500 words. Let charts and tables carry the data.
5. Include a "Limitations" section:
- Small-N time series (8–11 years)
- LLM extremity scores are content-based, not independently validated beyond the manual audit (step 8)
- SVD axis stability caveat
- Coalition composition uncertainty
- Submitter party identification is parsed from motion titles and may be inaccurate for multi-submitter motions
- Keyword penetration not analyzed (circular)
**Patterns to follow:**
- `reports/drift/report.md` — existing report format
**Verification:**
- Report is readable, all chart references (2 figures from U2, 1 from U3) resolve, conclusion is grounded in the indicators.
- Report includes an explicit limitations section.
- Report does not claim statistical "proof" or "confirmation."
---
## System-Wide Impact
- **New table:** `overton_svd_center` is additive; no existing tables modified.
- **No UI changes:** Charts are saved as PNGs; report is markdown.
- **No agent_tools changes:** Analysis scripts are standalone.
- **Reproducibility:** All indicators are deterministic (no LLM calls). Rerunning produces identical results.
---
## Risks & Dependencies
| Risk | Mitigation |
|---|---|
| Coalition composition is wrong for a year | Document assumptions; if uncertain, note sensitivity in report. |
| SVD positions are noisy or unstable | Stability validation gate (U3); if unstable, report explicitly says so. |
| Report overclaims causality | Frame all findings as "indicators" or "consistent with," not proof. The Overton window is a theoretical construct; we measure observable correlates. |
| Opposition-only subset is too small post-2024 | Report N for each comparison; if N<10, flag as unreliable. |
| Submitter party misidentified from title | Use `voting_results` as sanity check (flag if parsed party has 0 "voor" votes); document as known limitation. |
| Small-N time series invites spurious pattern-matching | Explicit limitations section; no statistical tests presented as confirmatory. |
---
## Documentation / Operational Notes
- The report is intended for internal research consumption and potential academic/public communication.
- All code is standalone analysis scripts; no operational deployment.
- `overton_svd_center` table can be reused for future longitudinal analyses if SVD axes are validated as stable.
---
## Sources & References
- Origin analysis: `analysis/right_wing/direction3_migration_antidemocratic.py`
- SVD computation: `analysis/explorer_data.py`
- Classification logic: `analysis/right_wing/classify_motions.py`
- Keywords: `analysis/right_wing/right_wing_keywords.json`
- Chart patterns: `scripts/motion_drift.py`

@ -0,0 +1,165 @@
# Overton Window Breakpoint Analysis
**Goal:** Quantify the 2024 structural break in centrist support, pass rates,
and content extremity for right-wing motions in the Tweede Kamer.
**Analysis period:** 2016–2026
**Right-wing parties:** PVV, FVD, JA21, SGP
**Centrist parties:** VVD, D66, CDA, NSC, BBB, CU
**Left parties:** PvdA, GL, SP, PvdD, Volt, DENK, Bij1
---
## 1. Yearly Aggregate Metrics (All Right-Wing Motions)
| Year | N (RW) | Centrist Support | Pass Rate | Extremity | Right Support | Left Opp. |
|------|--------|-----------------|-----------|-----------|---------------|----------|
| 2016 | 6 | 0.722 | 1.000 | 2.00 | 1.000 | 0.708 |
| 2017 | 0 | N/A | N/A | N/A | N/A | N/A |
| 2018 | 5 | 1.000 | 1.000 | 1.40 | 0.800 | 0.480 |
| 2019 | 195 | 0.410 | 0.969 | 2.14 | 0.838 | 0.746 |
| 2020 | 469 | 0.326 | 0.979 | 2.26 | 0.818 | 0.758 |
| 2021 | 425 | 0.339 | 0.962 | 2.24 | 0.903 | 0.788 |
| 2022 | 446 | 0.404 | 0.926 | 2.16 | 0.891 | 0.820 |
| 2023 | 365 | 0.457 | 0.962 | 2.24 | 0.900 | 0.821 |
| 2024 | 469 | 0.670 | 1.000 | 1.99 | 0.885 | 0.756 |
| 2025 | 455 | 0.597 | 0.996 | 2.25 | 0.895 | 0.799 |
| 2026 | 151 | 0.518 | 0.927 | 2.33 | 0.916 | 0.834 |
## 2. Pre/Post 2024 Comparison
**Break year:** 2024
### All right-wing motions
| Metric | Pre-2024 Mean | Post-2024 Mean | Δ | Cohen's d |
|--------|--------------|---------------|-----|-----------|
| Centrist Support | 0.384 | 0.618 | +0.234 | +0.68 |
| Pass Rate | 0.959 | 0.988 | +0.029 | +0.18 |
| Extremity | 2.21 | 2.15 | -0.07 | -0.09 |
**Interpretation:** Cohen's d values quantify effect sizes (|d| < 0.2 small, 0.5 medium, > 0.8 large).
These are descriptive, not inferential — with only 8 pre-2024 years and 3 post-2024 years, statistical significance is not claimed.
### Opposition-only right-wing motions
| Metric | Pre-2024 Mean | Post-2024 Mean | Δ | Cohen's d | N pre / N post |
|--------|--------------|---------------|-----|-----------|---------------|
| Centrist Support | 0.270 | 0.543 | +0.272 | +0.85 | 1295 / 405 |
| Pass Rate | 0.954 | 0.985 | +0.031 | +0.18 | 1295 / 405 |
| Extremity | 2.28 | 2.18 | -0.10 | -0.14 | 1295 / 405 |
**Interpretation gate:** If opposition metrics also rise post-2024, the shift is not
purely coalition-driven. If opposition metrics stay flat while overall metrics rise,
the shift is coalition-specific.
## 3. Coalition Composition
2016-2017: Rutte II (VVD/PvdA). 2018-2021: Rutte III (VVD/CDA/D66/CU). 2022-2023: Rutte IV (VVD/D66/CDA/CU). 2024-2026: Schoof (PVV/VVD/NSC/BBB). 2024 ambiguous: Schoof cabinet started July 2024; all 2024 motions are coded to the Schoof coalition. Coalition effect may be overestimated for early 2024.
Submitter party is parsed from motion title prefixes
(e.g., "Motie van het lid Wilders over ..."). Only the lead submitter's party is
considered. Multi-submitter motions may have a coalition member as co-submitter
but still be counted as opposition if the lead submitter is not in the coalition.
## 4. Domain Decomposition
Migration = category `asiel/vreemdelingen`. Non-migration = all other categories.
| Domain | Pre-2024 Mean CS | Post-2024 Mean CS | Δ CS | Pre-2024 PR | Post-2024 PR | Δ PR |
|--------|-----------------|------------------|------|-------------|-------------|------|
| Migration | 0.303 | 0.536 | +0.233 | 0.981 | 0.975 | -0.006 |
| Non-migration | 0.529 | 0.605 | +0.076 | 0.969 | 0.974 | +0.005 |
## 5. Extremity-Stratified Pass Rate
| Bucket | Period | N | Pass Rate | Δ (post-pre) |
|--------|--------|---|-----------|-------------|
| 1-2 (mild) | Pre-2024 | 221 | 0.950 | |
| | Post-2024 | 181 | 1.000 | +0.050 |
| 2-3 (moderate) | Pre-2024 | 1205 | 0.949 | |
| | Post-2024 | 640 | 0.983 | +0.033 |
| 3-4 (high) | Pre-2024 | 352 | 0.983 | |
| | Post-2024 | 175 | 0.994 | +0.011 |
| 4-5 (extreme) | Pre-2024 | 133 | 0.992 | |
| | Post-2024 | 79 | 0.987 | -0.005 |
**Key test:** If high-extremity motions (3–5) went from low pass rate to high pass rate
while mild motions stayed flat, centrists are more tolerant of extreme content —
direct Overton shift evidence. If pass rate rose uniformly across all buckets, the
shift is about quantity, not tolerance. If only the 1–2 bucket rose, right-wing
parties filed milder motions post-2024 and the 'shift' is illusory.
## 6. Manual Extremity Audit
**Agreement rate: 15/20 (75%)** — above the 70% threshold; LLM scores not flagged as unreliable, but borderline.
**Identified systematic biases:**
- **Anti-institutional overrating:** LLM inflates scores on anti-EU, anti-government motions (e.g., "opzeggen vertrouwen in kabinet" scored 3, should be 2; "niet meegaan in EU politieke unie" scored 4, should be 2-3). Procedural or stance-taking motions scored as radical policy.
- **Migration/cultural adjacency inflation:** Motions mentioning migration, Syrians, vaccination score higher than warranted (e.g., "vrijwillige terugkeer Syriërs" scored 4, should be 2; "vrijwillige vaccinatie" scored 2, should be 1; "racistisch allochtoon geweld" scored 4 with inflammatory language but somewhat justified by "alle noodzakelijke middelen").
- **Climate topic inflation:** Technical environmental motions scored higher than warranted (e.g., "emissiegegevens beter afbakenen" scored 3, should be 2).
**Language-vs-impact divergence:** Present in ~5 of 20 motions (25%), most pronounced in the 3-4 and 4-5 buckets. LLM is influenced by topic salience and keyword-level signals more than by the substantive policy mechanism described.
| # | Year | Category | LLM Score | Bucket | Agreed? | Driver | Notes |
|---|------|----------|-----------|--------|---------|--------|-------|
| 1 | 2024 | economie/belasting | 1 | 1-2 (mild) | Y | Policy | EU directive implementation; routine |
| 2 | 2020 | economie/belasting | 1 | 1-2 (mild) | Y | Policy | Symbolic support for KLM; mild |
| 3 | 2019 | veiligheid/justitie | 1 | 1-2 (mild) | Y | Policy | Budget procedural; trivial |
| 4 | 2025 | economie/belasting | 1 | 1-2 (mild) | Y | Policy | Tax bracket indexing; routine |
| 5 | 2022 | sociaal/jeugd | 1 | 1-2 (mild) | Y | Policy | One-time parent benefit; limited scope |
| 6 | 2021 | corona/pandemie | 2 | 2-3 (moderate) | Y | Policy | Sport venue regulation; moderate |
| 7 | 2021 | zorg/gezondheid | 2 | 2-3 (moderate) | N (→1) | Language | Voluntary vaccination for at-risk; COVID rhetoric inflates |
| 8 | 2020 | economie/belasting | 2 | 2-3 (moderate) | Y | Policy | Government influence on port; moderate |
| 9 | 2025 | veiligheid/justitie | 2 | 2-3 (moderate) | Y | Both | Police oath reform; symbolic + mild policy |
| 10 | 2020 | economie/belasting | 2 | 2-3 (moderate) | Y | Policy | Corporate tax carryback; narrow fiscal |
| 11 | 2020 | veiligheid/justitie | 3 | 3-4 (high) | N (→2) | Language | Motion of no-confidence is parliamentary procedure, not radical policy |
| 12 | 2025 | klimaat/milieu | 3 | 3-4 (high) | N (→2) | Policy | Emission data scoping; narrow technical fix, inflated by climate topic |
| 13 | 2019 | asiel/vreemdelingen | 3 | 3-4 (high) | Y | Policy | Withdraw from UN Refugee Pact; substantive |
| 14 | 2019 | landbouw/stikstof | 3 | 3-4 (high) | Y | Policy | Substantially relax nitrogen rules; high environmental impact |
| 15 | 2020 | klimaat/milieu | 3 | 3-4 (high) | Y | Both | Wolf culling permits; inflammatory topic but permit-framework |
| 16 | 2020 | veiligheid/justitie | 4 | 4-5 (extreme) | Y | Both | "Street terrorists" + denaturalization; both inflammatory and materially extreme |
| 17 | 2021 | defensie/buitenland | 4 | 4-5 (extreme) | N (→2-3) | Language | Standard Eurosceptic position; "niet meegaan in verdere integratie" is moderate |
| 18 | 2023 | asiel/vreemdelingen | 4 | 4-5 (extreme) | Y | Policy | Asylum stop; radical policy against international obligations |
| 19 | 2025 | asiel/vreemdelingen | 4 | 4-5 (extreme) | N (→2) | Language | *Voluntary* return of Syrians is moderate policy; migration topic inflates |
| 20 | 2019 | sociaal/jeugd | 4 | 4-5 (extreme) | Y | Both | "All necessary means against racist immigrant violence"; inflammatory + broad powers
## 7. Limitations
- **Small-N time series:** 8 pre-2024 years and at most 3 post-2024 years (2026 is partial).
Effect sizes are descriptive, not confirmatory.
- **LLM extremity scores:** Content-based, not independently validated beyond the
manual audit above. See §6 for agreement rate and noted biases.
- **Coalition composition:** Hardcoded per year. 2024 is ambiguous (Rutte IV until July,
Schoof thereafter). Early 2024 motions may be miscoded as Schoof-era.
- **Submitter party identification:** Parsed from motion title prefixes (e.g.,
'Motie van het lid X'). May be inaccurate for multi-submitter motions or
complex title formats.
- **Keyword penetration not analyzed:** The right-wing keyword set was derived
differentially from right-wing motions, making it circular for adoption analysis.
- **Pass rate baseline:** Computed across all motions with voting data. Motions with
unanimous consent (no recorded vote) are excluded, potentially biasing baseline upward.
## 8. Figures
![Figure 1: Centrist Support and Pass Rate](breakpoint_figure_1.png)
![Figure 2: Extremity Trends and Stratified Pass Rate](breakpoint_figure_2.png)
## 9. Conclusion
### Core finding: Centrist support for right-wing motions surged post-2024 (d=+0.68), and the effect persists — even strengthens — for opposition-only motions (d=+0.85). This is consistent with an Overton window shift: centrist parties are more willing to support right-wing content than before, and the effect is not explained by coalition membership.
### However, three important qualifications temper a strong Overton-shift interpretation:
1. **Content extremity did not increase** (d=-0.09). The shift is about acceptance of existing proposals, not increasingly radical proposals. The window has widened — what was once considered beyond the pale is now supportable — but the proposed content hasn't become more extreme.
2. **Pass rate is near ceiling** (96%+ in all periods). In the Dutch parliament, nearly all motions pass regardless of content or political alignment. Pass rate is insensitive as a shift indicator. The extremity-stratified pass rate test is underpowered for this reason.
3. **LLM extremity scores are imperfect** (75% audit agreement; borderline). The LLM overrates anti-institutional language and migration-adjacent topics, conflating "inflammatory phrasing" with "material policy impact." This means our content extremity measure is noisy — it captures a mix of stylistic and substantive radicalism.
### The migration-centric pattern
The shift is concentrated in migration (centrist support Δ=+0.233) with non-migration showing a much smaller effect (Δ=+0.076). Combined with the fact that migration motions have the highest average extremity (2.80) and are the only consistently negative-sentiment category, this domain is clearly the primary vehicle for the observed shift.

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

@ -0,0 +1,134 @@
# Overton Window Shift in the Dutch Parliament: Findings Report
**Date:** 2026-05-08
**Branch:** feat/right-wing-motion-analysis
**Analysis period:** 2016–2026
---
## 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.
---
## 2. Indicator 1: Centrist Support Breakpoint
### Core finding
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)
### Pass rate is an insensitive measure
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.
### Domain decomposition
The shift is heavily migration-centric:
| Domain | Pre-2024 CS | Post-2024 CS | Δ |
|--------|------------|-------------|---|
| Migration | 0.303 | 0.536 | +0.233 |
| Non-migration | 0.529 | 0.605 | +0.076 |
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
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.
---
## 3. Indicator 2: Content Extremity Trend
### 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.
![Figure 2: Extremity Trends and Stratified Pass Rate](breakpoint_figure_2.png)
### LLM scoring reliability
A stratified manual audit of 20 motions (5 per extremity bucket) achieved **75% agreement** (15/20), above the 70% threshold but borderline. Identified biases:
- **Anti-institutional overrating:** LLM inflates scores on anti-EU and anti-government motions (procedural stances scored as radical policy)
- **Migration/cultural adjacency inflation:** Motions mentioning migration-adjacent topics score higher than warranted
- **Climate topic inflation:** Technical environmental motions scored higher than warranted
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).
---
## 4. Indicator 3: SVD Spatial Drift — INCONCLUSIVE
### Stability gate: FAILED
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.
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.
![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.
### Path forward
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.
---
## 5. Synthesis
### What we can say with confidence
1. **Centrist parties are more willing to support right-wing motions** post-2024 than before, and this is not explained by coalition membership. Cohen's d = +0.85 for opposition-only motions represents a large effect.
2. **The shift is migration-centric.** Migration motions saw +0.233 centrist support gain; non-migration saw only +0.076. Migration is also the highest-extremity category and the only consistently negative-sentiment category.
3. **Content extremity did not increase.** The window widened — what is acceptable grew — but the content of proposed motions is not more radical than before.
### What we cannot say
1. **We cannot claim spatial (SVD) drift.** Axes are too unstable for cross-window comparison.
2. **We cannot quantify how much of the shift is topic-driven vs. ideology-driven.** Migration is inherently more controversial than other policy domains. If the volume of migration motions increased post-2024, centrist support for the category may reflect the topic's higher baseline controversy rather than shifting ideology.
3. **We cannot distinguish between sincere ideological shift and strategic adjustment.** Centrist parties may genuinely agree more with right-wing content, or they may be voting differently for coalition-management or electoral reasons.
### Uncertainty hierarchy
| Evidence Level | Indicator | Status |
|---------------|-----------|--------|
| **Strong** | Centrist support surge (opposition-controlled) | Confirmed |
| **Moderate** | Migration-specificity of the shift | Confirmed |
| **Inconclusive** | Extremity-stratified tolerance shift | Underpowered (pass rate ceiling) |
| **Inconclusive** | SVD spatial drift | Axes unstable |
| **Weak** | Content extremity trend | No increase (but LLM scoring imperfect) |
---
## 6. Limitations
- **Small-N time series:** 8 pre-2024 years, 3 post-2024 years (2026 is partial). Effect sizes are descriptive, not confirmatory.
- **LLM extremity scores:** 75% audit agreement; borderline. Scores conflate stylistic and substantive radicalism. See deferred follow-up work for two-dimensional rescoring plan.
- **SVD axis instability:** Cross-window SVD comparison is invalid without alignment. Spatial indicator discarded.
- **Coalition composition:** Hardcoded per year. 2024 is ambiguous (Rutte IV until July, Schoof thereafter). Early 2024 motions may be miscoded.
- **Submitter party identification:** Parsed from motion title prefixes. ~10% of motions have non-standard titles (bills, amendments) and are excluded from opposition-only analysis.
- **Pass rate baseline:** Computed across motions with recorded votes. Unanimous consent motions are excluded, potentially biasing baseline upward. The Dutch parliament's near-universal passage rate makes pass rate a poor sensitivity measure.
---
## 7. Figures
1. `breakpoint_figure_1.png` — Centrist support and pass rate over time (all RW, opposition-only, migration, non-migration, + baseline)
2. `breakpoint_figure_2.png` — Extremity trends and extremity-stratified pass rate (pre vs. post 2024)
3. `svd_drift_chart.png` — SVD centrist center trajectory (unreliable — axes unstable)
---
## 8. Next Steps
1. **Commit findings** and archive the analysis on `feat/right-wing-motion-analysis`.
2. **Two-dimensional extremity rescoring** (deferred): Validate whether LLM scores capture stylistic vs. material radicalism on a stratified sample. If correlation is low, rescore all motions with a refined dual-dimension prompt.
3. **Procrustes-aligned SVD** (optional): If spatial evidence is desired, rerun U3 using `load_party_scores_all_windows_aligned` from `explorer_data.py` for a common reference frame.
4. **Temporal decomposition of migration vs. other domains:** The 2024 shift may be partially explained by the increased volume of migration motions, rather than a general rightward shift. Disentangle topic composition from ideological drift.

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

@ -0,0 +1,60 @@
# SVD Center Drift & Axis Stability Report
## Axis Stability Validation
**Stability threshold:** Spearman ρ ≥ 0.7 for both axes. Maximum unstable pairs allowed: 2.
**Result:** 9 unstable pair(s) out of 10 consecutive window pairs.
**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.**
- Mean axis-1 correlation: 0.0054
- Mean axis-2 correlation: 0.2128
### Per-Pair Stability Details
| Window Pair | Axis 1 ρ | Axis 2 ρ | Unstable | Shared Parties |
|---|---|---|---|---|
| 2016-2017 | -0.439 | 0.257 | **YES** | BBB, CDA, ChristenUnie, D66, DENK, GrBvK, GroenLinks-PvdA, Houwers, Klein, Krol, Monasch, NSC, PVV, PvdD, SGP, SP, VVD, Van Vliet |
| 2017-2018 | -0.779 | 0.876 | **YES** | 50PLUS, BBB, CDA, ChristenUnie, D66, DENK, FVD, GroenLinks-PvdA, Groep Van Haga, Krol, NSC, PVV, PvdD, SGP, SP, VVD |
| 2018-2019 | 0.897 | -0.024 | **YES** | 50PLUS, BBB, CDA, ChristenUnie, D66, DENK, FVD, GroenLinks-PvdA, Groep Van Haga, Krol, NSC, PVV, PvdD, SGP, SP, VVD |
| 2019-2020 | -0.819 | 0.353 | **YES** | 50PLUS, BBB, CDA, ChristenUnie, D66, DENK, FVD, GroenLinks-PvdA, Groep Van Haga, Krol, NSC, PVV, PvdD, SGP, SP, VVD, vKA |
| 2020-2021 | 0.797 | -0.772 | **YES** | 50PLUS, BBB, CDA, ChristenUnie, D66, DENK, FVD, GroenLinks-PvdA, Groep Van Haga, Krol, NSC, PVV, PvdD, SGP, SP, VVD, vKA |
| 2021-2022 | 0.893 | 0.910 | no | BBB, BIJ1, CDA, ChristenUnie, D66, DENK, Ephraim, FVD, Fractie Den Haan, GroenLinks-PvdA, Groep Van Haga, JA21, NSC, PVV, PvdD, SGP, SP, VVD, Volt |
| 2022-2023 | 0.889 | -0.379 | **YES** | BBB, BIJ1, CDA, ChristenUnie, D66, DENK, Ephraim, FVD, Fractie Den Haan, GroenLinks-PvdA, Groep Van Haga, JA21, NSC, PVV, PvdD, SGP, SP, VVD, Volt |
| 2023-2024 | -0.229 | 0.821 | **YES** | BBB, CDA, ChristenUnie, D66, DENK, FVD, GroenLinks-PvdA, JA21, NSC, PVV, PvdD, SGP, SP, VVD, Volt |
| 2024-2025 | -0.757 | 0.779 | **YES** | BBB, CDA, ChristenUnie, D66, DENK, FVD, GroenLinks-PvdA, JA21, NSC, PVV, PvdD, SGP, SP, VVD, Volt |
| 2025-2026 | -0.400 | -0.694 | **YES** | 50PLUS, BBB, CDA, ChristenUnie, D66, DENK, FVD, GroenLinks-PvdA, JA21, NSC, PVV, PvdD, SGP, SP, VVD, Volt |
## Centrist Center of Gravity
| Window | Centrist Ax1 | Centrist Ax2 | Right Ax1 | Right Ax2 | Centrist Parties Present | Right Parties Present |
|---|---|---|---|---|---|---|
| 2016 | 5.1514 | 0.1220 | 5.6432 | 0.5936 | BBB, CDA, ChristenUnie, D66, NSC, VVD | PVV, SGP |
| 2017 | -3.6524 | -0.7100 | -3.7095 | 1.3546 | BBB, CDA, ChristenUnie, D66, NSC, VVD | FVD, PVV, SGP |
| 2018 | 5.1662 | -0.8347 | 2.2336 | 3.7263 | BBB, CDA, ChristenUnie, D66, NSC, VVD | FVD, PVV, SGP |
| 2019 | 11.7312 | -2.6126 | 7.1092 | 6.5966 | BBB, CDA, ChristenUnie, D66, NSC, VVD | FVD, PVV, SGP |
| 2020 | -27.1482 | 2.8304 | -9.2387 | -14.1063 | BBB, CDA, ChristenUnie, D66, NSC, VVD | FVD, PVV, SGP |
| 2021 | -15.9032 | -0.6795 | -6.3142 | 19.8728 | BBB, CDA, ChristenUnie, D66, NSC, VVD | FVD, JA21, PVV, SGP |
| 2022 | -28.5270 | -0.6204 | -3.8504 | 19.8373 | BBB, CDA, ChristenUnie, D66, NSC, VVD | FVD, JA21, PVV, SGP |
| 2023 | -15.7130 | 0.1571 | -5.0623 | -15.8667 | BBB, CDA, ChristenUnie, D66, NSC, VVD | FVD, JA21, PVV, SGP |
| 2024 | 26.2822 | 19.2003 | 24.9072 | -0.8372 | BBB, CDA, ChristenUnie, D66, NSC, VVD | FVD, JA21, PVV, SGP |
| 2025 | -8.1667 | 12.0249 | -14.5604 | 1.7604 | BBB, CDA, ChristenUnie, D66, NSC, VVD | FVD, JA21, PVV, SGP |
| 2026 | -13.6251 | 3.4321 | -3.8011 | 16.7538 | BBB, CDA, ChristenUnie, D66, NSC, VVD | FVD, JA21, PVV, SGP |
## Drift Metrics (UNRELIABLE — Axes Unstable)
Drift metrics were computed but are unreliable due to axis instability. Cross-window comparisons on unstable axes conflate positional change with axis re-orientation.
## Chart
![SVD Drift Chart](svd_drift_chart.png)
## 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.
---
*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*
Loading…
Cancel
Save