diff --git a/.opencode/skills/score-extremity/SKILL.md b/.opencode/skills/score-extremity/SKILL.md index a3fe8d4..a428564 100644 --- a/.opencode/skills/score-extremity/SKILL.md +++ b/.opencode/skills/score-extremity/SKILL.md @@ -9,11 +9,13 @@ Score Dutch parliamentary motions on TWO independent dimensions: 1. **Stijl-extremiteit (stylistic extremity, 1–5):** How inflammatory, harsh, or rhetorically charged is the language? 1 = neutral/technical, 5 = openly hostile/discriminatory language. -2. **Materiele impact (material impact, 1–5):** How much would this policy actually restrict rights, affect specific groups, or reshape institutions if enacted? 1 = procedural/ministerial request, 5 = fundamental rights restriction or institutional dismantling. +2. **Materiele impact (material impact, 1–5):** How fundamentally would this policy change the status quo if enacted? How many people are affected and how deeply? Score based on the scale and permanence of the change, regardless of political direction. 1 = procedural/ministerial request, 5 = fundamental restructuring of rights, institutions, or economic systems. These dimensions are independent. A motion can be: - High stylistic, low material: "Alle buitenlanders moeten het land uit!" (inflammatory but legally vacuous) -- Low stylistic, high material: "Het recht op gezinshereniging wordt beperkt tot kerngezin met inkomenseis van 150% minimumloon" (measured language but concretely restricts rights) +- Low stylistic, high material: "De zorgpremie wordt inkomensafhankelijk en de bijdrage loopt op tot 15% van het inkomen" (measured language but fundamentally restructures healthcare funding) +- Low stylistic, high material (restriction): "Het recht op gezinshereniging wordt beperkt tot kerngezin met inkomenseis van 150% minimumloon" (measured language but concretely restricts rights) +- High stylistic, high material: "Nederland stapt per direct uit de Europese Unie" (inflammatory AND structurally transformative) ## Scoring Prompt @@ -30,8 +32,10 @@ Hoe fel/opruiend/geladen is het taalgebruik? Let op woordkeuze, toon, en retoris 1 = neutraal/technisch/ambtelijk, 3 = stellige politieke taal/waardeoordelen, 5 = vijandig/discriminerend/haatdragend taalgebruik. 2) MATERIELE IMPACT (1-5): -Hoe ingrijpend is het voorgestelde beleid als het wordt uitgevoerd? Wie wordt geraakt en hoe? -1 = procedureel/symbolisch/verzoek aan minister, 3 = concrete beleidswijziging met meetbare gevolgen, 5 = fundamentele inperking van rechten/ontmanteling van instituties/grootschalige uitsluiting. +Hoe fundamenteel verandert dit voorstel de status quo? Hoeveel mensen worden geraakt en hoe diep? +Scoor op basis van de schaal en duurzaamheid van de verandering, ongeacht politieke richting. +Linkse én rechtse moties kunnen hoge impact hebben — het gaat om hoe ingrijpend de verandering is. +1 = procedureel/symbolisch/onderzoeksverzoek, 3 = concrete beleidswijziging met meetbare gevolgen voor een sector/doelgroep, 5 = fundamentele herstructurering van rechten, instituties of economische systemen met langdurige gevolgen voor de hele samenleving. Geef voor elke dimensie een score van 1-5 en een korte toelichting in het Nederlands. ``` diff --git a/README.md b/README.md index ad7932c..79732d2 100644 --- a/README.md +++ b/README.md @@ -63,8 +63,13 @@ The app will be available at http://localhost:8501. - **[ARCHITECTURE.md](ARCHITECTURE.md)** — Comprehensive architecture overview, tech stack, and contributor guidance - **[CODE_STYLE.md](CODE_STYLE.md)** — Coding conventions, naming, typing, and testing standards - **[docs/solutions/](docs/solutions/)** — Documented solutions to past bugs and best practices -- **[Overton Window Analysis](reports/overton_window/overton_window_synthesis.md)** — Full analysis of whether the Dutch Overton window shifted right (2016–2026) -- **[Overton Blog Post](reports/overton_window/blog_post.html)** — Public-facing summary with visualizations + +### Research + +- **[Overton Window Article](reports/overton_window/overton_window.qmd)** — Interactive article: "Has the Dutch Overton window shifted?" with Plotly charts (render with `quarto render`) +- **[Overton Synthesis](reports/overton_window/overton_window_synthesis.md)** — Detailed synthesis of all indicators and the "acceptance through moderation" verdict +- **[Overton Reports](reports/overton_window/)** — 13 appendix reports covering breakpoint analysis, SVD drift, 2D extremity, mechanisms, and more ([reading guide](reports/overton_window/README.md)) +- **[Overton Dashboard](reports/overton_window/overton_report.html)** — Standalone HTML report with gravity-controlled charts and example motions ## Tech Stack diff --git a/ai_provider.py b/ai_provider.py index 1accc19..99457e0 100644 --- a/ai_provider.py +++ b/ai_provider.py @@ -57,7 +57,7 @@ def _post_with_retries( for attempt in range(1, retries + 1): try: resp = requests.post(url, json=json, headers=headers, timeout=60) - except requests.ConnectionError as exc: + except (requests.ConnectionError, requests.Timeout) as exc: if attempt == retries: raise ProviderError( f"Connection error when calling provider: {exc}" diff --git a/analysis/right_wing/build_all_reports.py b/analysis/right_wing/build_all_reports.py new file mode 100644 index 0000000..728d4a1 --- /dev/null +++ b/analysis/right_wing/build_all_reports.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 +"""Regenerate all Overton window reports in correct dependency order. + +Usage: + uv run python analysis/right_wing/build_all_reports.py + uv run python analysis/right_wing/build_all_reports.py --skip-llm +""" + +from __future__ import annotations + +import argparse +import logging +import subprocess +import sys +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from analysis.right_wing.common import REPORTS_DIR + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", + datefmt="%H:%M:%S", +) +logger = logging.getLogger("build_all_reports") + +SCRIPT_DIR = ROOT / "analysis" / "right_wing" + +PHASE_1_SCRIPTS = [ + "overton_breakpoint_analysis.py", + "temporal_trajectory.py", + "causal_timing.py", + "party_differentiation.py", + "voting_margin.py", + "left_wing_response.py", + "success_correlation.py", + "overton_svd_drift.py", + "svd_trajectory_viz.py", +] + +PHASE_1_OUTPUTS = [ + "breakpoint_analysis.md", + "breakpoint_figure_1.png", + "breakpoint_figure_2.png", + "breakpoint_figure_3.png", + "breakpoint_figure_4.png", + "temporal_trajectory.md", + "temporal_trajectory_figure.png", + "causal_timing.md", + "causal_timing_figure.png", + "party_differentiation.md", + "party_differentiation_figure.png", + "voting_margin.md", + "voting_margin_figure.png", + "left_wing_response.md", + "left_wing_response_figure.png", + "success_correlation.md", + "svd_drift_chart.png", + "svd_stability_report.md", + "svd_trajectory_figure.png", +] + +PHASE_2_SCRIPTS = [ + "extremity_2d_temporal.py", + "predictive_model.py", + "mechanism_classification.py", +] + +PHASE_2_OUTPUTS = [ + "extremity_2d_temporal.md", + "extremity_2d_temporal_figure.png", + "predictive_model.md", + "predictive_model_figure.png", + "mechanism_classification.md", +] + +PHASE_3_SCRIPTS = [ + "derive_categories.py", +] + + +def _script_path(name: str) -> str: + return str(SCRIPT_DIR / name) + + +def _run_script(name: str) -> bool: + """Run a single script via subprocess. Returns True on success.""" + logger.info("Running %s ...", name) + t0 = time.perf_counter() + try: + subprocess.run( + [sys.executable, _script_path(name)], + cwd=str(ROOT), + check=True, + capture_output=True, + text=True, + ) + elapsed = time.perf_counter() - t0 + logger.info("Finished %s (%.1fs)", name, elapsed) + return True + except subprocess.CalledProcessError as exc: + elapsed = time.perf_counter() - t0 + logger.error("Script %s failed after %.1fs (rc=%d)", name, elapsed, exc.returncode) + if exc.stdout: + for line in exc.stdout.strip().splitlines(): + logger.error(" stdout: %s", line) + if exc.stderr: + for line in exc.stderr.strip().splitlines(): + logger.error(" stderr: %s", line) + return False + + +def _verify_outputs(files: list[str]) -> list[str]: + """Return list of expected output files that are missing.""" + missing = [] + for f in files: + if not (REPORTS_DIR / f).exists(): + missing.append(f) + return missing + + +def _run_phase( + phase_label: str, scripts: list[str], expected_outputs: list[str] +) -> tuple[list[str], list[str]]: + """Run a list of scripts and verify outputs. Returns (succeeded, failed).""" + logger.info("=" * 50) + logger.info("Phase %s", phase_label) + logger.info("=" * 50) + + succeeded = [] + failed = [] + + for script in scripts: + ok = _run_script(script) + if ok: + succeeded.append(script) + else: + failed.append(script) + + missing = _verify_outputs(expected_outputs) + if missing: + logger.warning( + "Phase %s: %d expected output(s) missing after run:\n %s", + phase_label, + len(missing), + "\n ".join(missing), + ) + else: + logger.info("Phase %s: all expected outputs present.", phase_label) + + return succeeded, failed + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Regenerate all Overton window reports in dependency order." + ) + parser.add_argument( + "--skip-llm", + action="store_true", + help="Skip LLM-dependent phase (derive_categories.py)", + ) + args = parser.parse_args() + + REPORTS_DIR.mkdir(parents=True, exist_ok=True) + + all_succeeded: list[str] = [] + all_failed: list[str] = [] + t_start = time.perf_counter() + + # Phase 1: database-dependent (no LLM) + s, f = _run_phase("1 — database-dependent", PHASE_1_SCRIPTS, PHASE_1_OUTPUTS) + all_succeeded.extend(s) + all_failed.extend(f) + + # Phase 2: 2D extremity-dependent (no LLM) + s, f = _run_phase("2 — 2D extremity-dependent", PHASE_2_SCRIPTS, PHASE_2_OUTPUTS) + all_succeeded.extend(s) + all_failed.extend(f) + + # Phase 3: LLM-dependent + if not args.skip_llm: + s, f = _run_phase("3 — LLM-dependent", PHASE_3_SCRIPTS, []) + all_succeeded.extend(s) + all_failed.extend(f) + else: + logger.info("Skipping LLM-dependent phase (--skip-llm).") + + total_elapsed = time.perf_counter() - t_start + + # Summary + sep = "=" * 50 + print(f"\n{sep}") + print("BUILD SUMMARY") + print(sep) + print(f" Total time: {total_elapsed:.1f}s") + print(f" Succeeded: {len(all_succeeded)}/{len(all_succeeded) + len(all_failed)}") + if all_succeeded: + print(" Scripts OK:") + for name in all_succeeded: + print(f" ✓ {name}") + if all_failed: + print(" Scripts FAILED:") + for name in all_failed: + print(f" ✗ {name}") + print(sep) + + return 1 if all_failed else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/analysis/right_wing/extremity_2d_temporal.py b/analysis/right_wing/extremity_2d_temporal.py index a8541e8..12bd3d8 100644 --- a/analysis/right_wing/extremity_2d_temporal.py +++ b/analysis/right_wing/extremity_2d_temporal.py @@ -46,7 +46,7 @@ def fetch_2d_yearly_data(con: duckdb.DuckDBPyConnection) -> dict[int, dict[str, """Join extremity_scores_2d with right_wing_motions to get yearly scores. Returns dict keyed by year, each containing lists of stylistic, material, - and original text_score values. + and original text_score values, plus gravity-filtered buckets (M>=3, M>=4). """ rows = con.execute(""" SELECT @@ -75,6 +75,10 @@ def fetch_2d_yearly_data(con: duckdb.DuckDBPyConnection) -> dict[int, dict[str, "non_mig_stijl": [], "non_mig_materieel": [], "non_mig_text": [], + "ge3_stijl": [], + "ge3_materieel": [], + "ge4_stijl": [], + "ge4_materieel": [], } for year, stijl, materieel, text_score, category in rows: @@ -93,6 +97,46 @@ def fetch_2d_yearly_data(con: duckdb.DuckDBPyConnection) -> dict[int, dict[str, yearly[y]["text"].append(float(text_score)) (yearly[y]["mig_text"] if is_mig else yearly[y]["non_mig_text"]).append(float(text_score)) + if stijl is not None and materieel is not None: + if float(materieel) >= 3: + yearly[y]["ge3_stijl"].append(float(stijl)) + yearly[y]["ge3_materieel"].append(float(materieel)) + if float(materieel) >= 4: + yearly[y]["ge4_stijl"].append(float(stijl)) + yearly[y]["ge4_materieel"].append(float(materieel)) + + return yearly + + +def fetch_all_motion_yearly(con: duckdb.DuckDBPyConnection) -> dict[int, dict[str, list[float]]]: + """Join extremity_scores_all with motions to get yearly scores for ALL motions. + + Returns dict keyed by year, each containing stijl and materieel lists. + """ + logger.info("Fetching all-motion extremity data by year...") + rows = con.execute(""" + SELECT + EXTRACT(YEAR FROM m.date) AS year, + esa.stijl_extremiteit, + esa.materiele_impact + FROM extremity_scores_all esa + JOIN motions m ON esa.motion_id = m.id + WHERE m.date IS NOT NULL + AND EXTRACT(YEAR FROM m.date) BETWEEN ? AND ? + ORDER BY year + """, (YEAR_MIN, YEAR_MAX)).fetchall() + + yearly: dict[int, dict[str, list[float]]] = {} + for year in range(YEAR_MIN, YEAR_MAX + 1): + yearly[year] = {"stijl": [], "materieel": []} + + for year, stijl, materieel in rows: + y = int(year) + yearly[y]["stijl"].append(float(stijl)) + yearly[y]["materieel"].append(float(materieel)) + + total = sum(len(v["stijl"]) for v in yearly.values()) + logger.info("Fetched %d all-motion scored motions across %d years", total, len(yearly)) return yearly @@ -110,9 +154,11 @@ def compute_yearly_summary( ("", ["stijl", "materieel", "text"]), ("mig_", ["mig_stijl", "mig_materieel", "mig_text"]), ("non_mig_", ["non_mig_stijl", "non_mig_materieel", "non_mig_text"]), + ("ge3_", ["ge3_stijl", "ge3_materieel"]), + ("ge4_", ["ge4_stijl", "ge4_materieel"]), ]: for key in keys: - short = key.replace("non_mig_", "").replace("mig_", "") + short = key.replace("non_mig_", "").replace("mig_", "").replace("ge3_", "").replace("ge4_", "") vals = np.array(d.get(key, [])) n = len(vals) s[f"{prefix}n_{short}"] = n @@ -187,11 +233,45 @@ def compute_yearly_summary( s.get("mean_non_mig_stijl") is not None and not np.isnan(s.get("mean_non_mig_stijl", float("nan"))): s["gap_non_mig"] = s["mean_non_mig_materieel"] - s["mean_non_mig_stijl"] + # Gravity gaps + s["gap_ge3"] = float("nan") + if s.get("ge3_mean_materieel") is not None and not np.isnan(s.get("ge3_mean_materieel", float("nan"))) and \ + s.get("ge3_mean_stijl") is not None and not np.isnan(s.get("ge3_mean_stijl", float("nan"))): + s["gap_ge3"] = s["ge3_mean_materieel"] - s["ge3_mean_stijl"] + + s["gap_ge4"] = float("nan") + if s.get("ge4_mean_materieel") is not None and not np.isnan(s.get("ge4_mean_materieel", float("nan"))) and \ + s.get("ge4_mean_stijl") is not None and not np.isnan(s.get("ge4_mean_stijl", float("nan"))): + s["gap_ge4"] = s["ge4_mean_materieel"] - s["ge4_mean_stijl"] + summary[year] = s return summary +def compute_all_motion_summary( + yearly: dict[int, dict[str, list[float]]], +) -> dict[int, dict[str, Any]]: + """Compute simple yearly means for all-motion data (no stratification).""" + summary: dict[int, dict[str, Any]] = {} + for year, d in yearly.items(): + s: dict[str, Any] = {"year": year} + for key in ["stijl", "materieel"]: + vals = np.array(d.get(key, [])) + n = len(vals) + s[f"n_{key}"] = n + if n > 0: + s[f"mean_{key}"] = float(np.mean(vals)) + s[f"std_{key}"] = float(np.std(vals, ddof=1)) if n > 1 else 0.0 + s[f"sem_{key}"] = float(np.std(vals, ddof=1) / np.sqrt(n)) if n > 1 else 0.0 + else: + s[f"mean_{key}"] = float("nan") + s[f"std_{key}"] = float("nan") + s[f"sem_{key}"] = float("nan") + summary[year] = s + return summary + + def compute_divergence_test( yearly: dict[int, dict[str, list[float]]], ) -> dict[str, Any]: @@ -286,14 +366,20 @@ def compute_temporal_correlations(summary: dict[int, dict[str, Any]]) -> dict[st return result -def create_figure(summary: dict[int, dict[str, Any]]) -> str: - """Generate the 2D extremity temporal figure with 3 panels.""" +def create_figure( + summary: dict[int, dict[str, Any]], + all_summary: dict[int, dict[str, Any]], +) -> str: + """Generate the 2D extremity temporal figure with 4 panels.""" years = sorted(summary.keys()) years_arr = np.array(years) def _val(yr, key): return summary[yr].get(key, float("nan")) + def _all_val(yr, key): + return all_summary[yr].get(key, float("nan")) if yr in all_summary else float("nan") + stijl_means = np.array([_val(y, "mean_stijl") for y in years]) mat_means = np.array([_val(y, "mean_materieel") for y in years]) text_means = np.array([_val(y, "mean_text") for y in years]) @@ -315,13 +401,25 @@ def create_figure(summary: dict[int, dict[str, Any]]) -> str: rs = np.array([_val(y, "r_stijl_mat") for y in years]) ns = np.array([_val(y, "n_stijl") for y in years]) - fig, (ax1, ax2, ax3) = plt.subplots(3, 1, figsize=(14, 14), sharex=True) + # Gravity data + ge3_stijl = np.array([_val(y, "ge3_mean_stijl") for y in years]) + ge3_mat = np.array([_val(y, "ge3_mean_materieel") for y in years]) + ge4_stijl = np.array([_val(y, "ge4_mean_stijl") for y in years]) + ge4_mat = np.array([_val(y, "ge4_mean_materieel") for y in years]) + + # All-motion data + all_stijl = np.array([_all_val(y, "mean_stijl") for y in years]) + all_mat = np.array([_all_val(y, "mean_materieel") for y in years]) + + fig, (ax1, ax2, ax3, ax4) = plt.subplots(4, 1, figsize=(14, 18), sharex=True) colour_stijl = "#E53935" colour_mat = "#1E88E5" colour_text = "#9E9E9E" + colour_ge3 = "#F9A825" + colour_ge4 = "#E65100" - # Panel 1: Yearly means with CIs + # Panel 1: Yearly means with CIs + gravity-weighted trends mask_stijl = ~np.isnan(stijl_means) mask_mat = ~np.isnan(mat_means) mask_text = ~np.isnan(text_means) @@ -342,13 +440,26 @@ def create_figure(summary: dict[int, dict[str, Any]]) -> str: ) ax1.plot(years_arr[mask_stijl], stijl_means[mask_stijl], - marker="o", color=colour_stijl, linewidth=2, label="Stylistic extremity") + marker="o", color=colour_stijl, linewidth=2, label="Stylistic extremity (all RW)") ax1.plot(years_arr[mask_mat], mat_means[mask_mat], - marker="s", color=colour_mat, linewidth=2, label="Material impact") + marker="s", color=colour_mat, linewidth=2, label="Material impact (all RW)") ax1.plot(years_arr[mask_text], text_means[mask_text], marker="^", color=colour_text, linewidth=1.5, linestyle="--", alpha=0.7, label="Original single-score") + # Gravity-weighted lines on Panel 1 + mask_ge3_stijl = ~np.isnan(ge3_stijl) + mask_ge3_mat = ~np.isnan(ge3_mat) + mask_ge4_stijl = ~np.isnan(ge4_stijl) + mask_ge4_mat = ~np.isnan(ge4_mat) + + ax1.plot(years_arr[mask_ge3_mat], ge3_mat[mask_ge3_mat], + marker="s", color=colour_ge3, linewidth=1.5, linestyle="--", alpha=0.8, + label="Material impact (M≥3)") + ax1.plot(years_arr[mask_ge4_mat], ge4_mat[mask_ge4_mat], + marker="s", color=colour_ge4, linewidth=1.5, linestyle=":", alpha=0.8, + label="Material impact (M≥4)") + 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), fontsize=9, color="black", alpha=0.7) @@ -399,17 +510,43 @@ def create_figure(summary: dict[int, dict[str, Any]]) -> str: ax3.annotate(f"r={r_val:.2f}\nn={int(n_val)}", xy=(xi, r_val), fontsize=7, ha="center", va="bottom", color="#4A148C") - ax3.set_xlabel("Year") ax3.set_ylabel("Pearson r (stijl, materieel)") ax3.set_title("Per-Year Correlation: Stylistic vs Material Impact", fontweight="bold") ax3.grid(True, alpha=0.3, axis="y") + # Panel 4: All-motion vs right-wing comparison + mask_all_stijl = ~np.isnan(all_stijl) + mask_all_mat = ~np.isnan(all_mat) + + ax4.plot(years_arr[mask_stijl], stijl_means[mask_stijl], + marker="o", color=colour_stijl, linewidth=2, label="RW Stylistic") + ax4.plot(years_arr[mask_mat], mat_means[mask_mat], + marker="s", color=colour_mat, linewidth=2, label="RW Material") + ax4.plot(years_arr[mask_all_stijl], all_stijl[mask_all_stijl], + marker="o", color=colour_stijl, linewidth=1.5, linestyle="--", alpha=0.6, + label="All-motion Stylistic") + ax4.plot(years_arr[mask_all_mat], all_mat[mask_all_mat], + marker="s", color=colour_mat, linewidth=1.5, linestyle="--", alpha=0.6, + label="All-motion Material") + + ax4.axvline(x=BREAK_YEAR - 0.5, color="black", linestyle=":", alpha=0.5, linewidth=1) + ax4.annotate("2024", xy=(BREAK_YEAR - 0.3, ax4.get_ylim()[1] * 0.95), + fontsize=9, color="black", alpha=0.7) + + ax4.set_xlabel("Year") + ax4.set_ylabel("Mean score (1-5 scale)") + ax4.set_title("All-Motion vs Right-Wing: Stylistic and Material Extremity", fontweight="bold") + ax4.legend(loc="upper left", fontsize=8) + ax4.grid(True, alpha=0.3) + ax1.set_xticks(years_arr) ax2.set_xticks(years_arr) ax3.set_xticks(years_arr) - ax3.set_xticklabels([str(y) for y in years], rotation=45) + ax4.set_xticks(years_arr) + ax4.set_xticklabels([str(y) for y in years], rotation=45) ax1.tick_params(labelbottom=False) ax2.tick_params(labelbottom=False) + ax3.tick_params(labelbottom=False) plt.tight_layout() path = str(REPORTS_DIR / "extremity_2d_temporal_figure.png") @@ -425,6 +562,7 @@ def generate_report( temporal_corr: dict[str, Any], yearly: dict[int, dict[str, list[float]]], fig_path: str, + all_summary: dict[int, dict[str, Any]], ) -> str: """Write the markdown report.""" years = sorted(summary.keys()) @@ -518,6 +656,32 @@ def generate_report( mig_r, mig_p = pearsonr(all_mig_stijl, all_mig_mat) if len(all_mig_stijl) >= 3 else (float("nan"), float("nan")) nm_r, nm_p = pearsonr(all_nm_stijl, all_nm_mat) if len(all_nm_stijl) >= 3 else (float("nan"), float("nan")) + # Gravity-weighted means (pre/post) + def pre_post_all_gap(key): + pre = [all_summary[y].get(key, float("nan")) for y in pre_years if y in all_summary] + post = [all_summary[y].get(key, float("nan")) for y in post_years if y in all_summary] + pre_valid = [v for v in pre if not np.isnan(v)] + post_valid = [v for v in post if not np.isnan(v)] + return (np.mean(pre_valid) if pre_valid else float("nan"), + np.mean(post_valid) if post_valid else float("nan")) + + pre_all_stijl, post_all_stijl = pre_post_all_gap("mean_stijl") + pre_all_mat, post_all_mat = pre_post_all_gap("mean_materieel") + + # Gravity-weighted means for right-wing + def pre_post_ge(key): + pre = [summary[y].get(key, float("nan")) for y in pre_years] + post = [summary[y].get(key, float("nan")) for y in post_years] + pre_valid = [v for v in pre if not np.isnan(v)] + post_valid = [v for v in post if not np.isnan(v)] + return (np.mean(pre_valid) if pre_valid else float("nan"), + np.mean(post_valid) if post_valid else float("nan")) + + pre_ge3_stijl, post_ge3_stijl = pre_post_ge("ge3_mean_stijl") + pre_ge3_mat, post_ge3_mat = pre_post_ge("ge3_mean_materieel") + pre_ge4_stijl, post_ge4_stijl = pre_post_ge("ge4_mean_stijl") + pre_ge4_mat, post_ge4_mat = pre_post_ge("ge4_mean_materieel") + lines = [ "# 2D Extremity Temporal Decomposition", "", @@ -525,7 +689,8 @@ def generate_report( "when stylistic and material extremity scores are analyzed separately over time.", "", "**Analysis period:** 2016-2026", - "**Data source:** `extremity_scores_2d` (2,869 motions scored) joined with `right_wing_motions`", + "**Data source (right-wing):** `extremity_scores_2d` (2,869 motions scored) joined with `right_wing_motions`", + "**Data source (all motions):** `extremity_scores_all` (29,570 motions scored) joined with `motions`", "**Domains:** Migration = `asiel/vreemdelingen`; Non-migration = all other categories", "", "> *Years with <50 scored motions are flagged for low confidence.", @@ -642,10 +807,13 @@ def generate_report( ] for domain_name, prefix in [("Migration", "mig_"), ("Non-migration", "non_mig_")]: - pre_s = np.nanmean([summary[y].get(f"{prefix}mean_stijl", float("nan")) for y in pre_years]) - pre_m = np.nanmean([summary[y].get(f"{prefix}mean_materieel", float("nan")) for y in pre_years]) - post_s = np.nanmean([summary[y].get(f"{prefix}mean_stijl", float("nan")) for y in post_years]) - post_m = np.nanmean([summary[y].get(f"{prefix}mean_materieel", float("nan")) for y in post_years]) + def _nanmean(vals): + valid = [v for v in vals if not np.isnan(v)] + return float(np.mean(valid)) if valid else float("nan") + pre_s = _nanmean([summary[y].get(f"{prefix}mean_stijl", float("nan")) for y in pre_years]) + pre_m = _nanmean([summary[y].get(f"{prefix}mean_materieel", float("nan")) for y in pre_years]) + post_s = _nanmean([summary[y].get(f"{prefix}mean_stijl", float("nan")) for y in post_years]) + post_m = _nanmean([summary[y].get(f"{prefix}mean_materieel", float("nan")) for y in post_years]) pre_g = pre_m - pre_s if not np.isnan(pre_s) and not np.isnan(pre_m) else float("nan") post_g = post_m - post_s if not np.isnan(post_s) and not np.isnan(post_m) else float("nan") @@ -663,23 +831,60 @@ def generate_report( "", "---", "", - "## 9. Figure", + "## 9. Gravity-Weighted Trends (Right-Wing)", + "", + "Yearly means for right-wing motions filtered by material impact thresholds.", + "M≥3 = motions with substantive material impact (score ≥ 3).", + "M≥4 = motions with fundamental material impact (score ≥ 4).", + "", + "| Year | N (all RW) | M≥3 N | M≥4 N | Stijl (all) | Stijl M≥3 | Stijl M≥4 | Mat (all) | Mat M≥3 | Mat M≥4 |", + "|------|-----------|-------|-------|-------------|-----------|-----------|-----------|---------|---------|", + ] + + for y in years: + s = summary[y] + lines.append( + f"| {y} " + f"| {int(s.get('n_stijl', 0))} " + f"| {int(s.get('ge3_n_stijl', 0))} " + f"| {int(s.get('ge4_n_stijl', 0))} " + f"| {fmt(s.get('mean_stijl'))} " + f"| {fmt(s.get('ge3_mean_stijl'))} " + f"| {fmt(s.get('ge4_mean_stijl'))} " + f"| {fmt(s.get('mean_materieel'))} " + f"| {fmt(s.get('ge3_mean_materieel'))} " + f"| {fmt(s.get('ge4_mean_materieel'))} |" + ) + + lines += [ + "", + "| Bucket | Pre-2024 Mean Stijl | Pre-2024 Mean Mat | Post-2024 Mean Stijl | Post-2024 Mean Mat |", + "|--------|-------------------|-------------------|---------------------|-------------------|", + f"| All RW | {fmt(pre_stijl)} | {fmt(pre_mat)} | {fmt(post_stijl)} | {fmt(post_mat)} |", + f"| M≥3 | {fmt(pre_ge3_stijl)} | {fmt(pre_ge3_mat)} | {fmt(post_ge3_stijl)} | {fmt(post_ge3_mat)} |", + f"| M≥4 | {fmt(pre_ge4_stijl)} | {fmt(pre_ge4_mat)} | {fmt(post_ge4_stijl)} | {fmt(post_ge4_mat)} |", + "", + "---", + "", + "## 10. Figure", "", f"![2D Extremity Temporal Figure]({Path(fig_path).name})", "", "**Figure panels:**", "- **Top panel:** Yearly mean stylistic (red) and material (blue) extremity scores with", " 95% bootstrap confidence intervals. Grey dashed line = original single-dimension", - " `text_score` for comparison.", - "- **Middle panel:** Gap trajectory (material minus stylistic) for all domains, migration,", + " `text_score` for comparison. Gold/orange lines show material impact for M≥3 and M≥4 subsets.", + "- **Second panel:** Gap trajectory (material minus stylistic) for all domains, migration,", " and non-migration. Positive gap = material impact exceeds stylistic extremity.", " A widening gap indicates increasing divergence between dimensions.", - "- **Bottom panel:** Per-year Pearson correlation between stylistic and material scores.", + "- **Third panel:** Per-year Pearson correlation between stylistic and material scores.", " Declining correlation over time suggests the two dimensions are decoupling.", + "- **Fourth panel:** All-motion (dashed) vs right-wing (solid) comparison for both stylistic", + " and material dimensions. Shows how right-wing trends compare to the full motion landscape.", "", "---", "", - "## 10. Limitations", + "## 11. Limitations", "", "- **Yearly resolution:** Year-level aggregation necessarily smooths within-year trends.", " The quarterly framework from U1 provides finer resolution for other metrics.", @@ -694,7 +899,45 @@ def generate_report( "", "---", "", - "## 11. Conclusion", + "## 12. All-Motion Comparison", + "", + "Yearly means for ALL motions (from `extremity_scores_all`) compared to right-wing-only means.", + "This provides context for whether right-wing trends reflect party-specific dynamics or broader", + "parliamentary trends.", + "", + "| Year | N (all) | All Stijl | All Mat | N (RW) | RW Stijl | RW Mat | Diff Stijl | Diff Mat |", + "|------|---------|-----------|---------|--------|----------|--------|------------|----------|", + ] + + for y in years: + s = summary[y] + a = all_summary.get(y, {}) + all_n = int(a.get("n_stijl", 0)) + all_s = fmt(a.get("mean_stijl")) + all_m = fmt(a.get("mean_materieel")) + rw_n = int(s.get("n_stijl", 0)) + rw_s = fmt(s.get("mean_stijl")) + rw_m = fmt(s.get("mean_materieel")) + diff_s = fmt(s.get("mean_stijl", float("nan")) - a.get("mean_stijl", float("nan")) if not np.isnan(s.get("mean_stijl", float("nan"))) and not np.isnan(a.get("mean_stijl", float("nan"))) else float("nan")) + diff_m = fmt(s.get("mean_materieel", float("nan")) - a.get("mean_materieel", float("nan")) if not np.isnan(s.get("mean_materieel", float("nan"))) and not np.isnan(a.get("mean_materieel", float("nan"))) else float("nan")) + lines.append( + f"| {y} | {all_n} | {all_s} | {all_m} | {rw_n} | {rw_s} | {rw_m} | {diff_s} | {diff_m} |" + ) + + # Pre/post for all-motion + lines += [ + "", + "| Period | All Stijl | All Mat | RW Stijl | RW Mat | Stijl Δ | Mat Δ |", + "|--------|-----------|---------|----------|--------|---------|-------|", + f"| Pre-2024 | {fmt(pre_all_stijl)} | {fmt(pre_all_mat)} | {fmt(pre_stijl)} | {fmt(pre_mat)} | {fmt(pre_stijl - pre_all_stijl if not np.isnan(pre_stijl) and not np.isnan(pre_all_stijl) else float('nan'))} | {fmt(pre_mat - pre_all_mat if not np.isnan(pre_mat) and not np.isnan(pre_all_mat) else float('nan'))} |", + f"| Post-2024 | {fmt(post_all_stijl)} | {fmt(post_all_mat)} | {fmt(post_stijl)} | {fmt(post_mat)} | {fmt(post_stijl - post_all_stijl if not np.isnan(post_stijl) and not np.isnan(post_all_stijl) else float('nan'))} | {fmt(post_mat - post_all_mat if not np.isnan(post_mat) and not np.isnan(post_all_mat) else float('nan'))} |", + "", + ] + + lines += [ + "---", + "", + "## 13. Conclusion", "", f"The overall stijl-materieel correlation is r={fmt(overall_r)} (p={fmt(overall_p, 6)}),", "consistent with the aggregate finding of r≈0.47.", @@ -727,11 +970,17 @@ def main() -> int: total_motions = sum(len(yearly[y]["stijl"]) for y in yearly) logger.info("Fetched %d scored motions across %d years", total_motions, len(yearly)) + logger.info("Fetching all-motion extremity data...") + all_yearly = fetch_all_motion_yearly(con) + con.close() logger.info("Computing yearly summary statistics...") summary = compute_yearly_summary(yearly) + logger.info("Computing all-motion yearly summary...") + all_summary = compute_all_motion_summary(all_yearly) + logger.info("Running divergence test (Wilcoxon)...") divergence = compute_divergence_test(yearly) @@ -739,10 +988,10 @@ def main() -> int: temporal_corr = compute_temporal_correlations(summary) logger.info("Generating figure...") - fig_path = create_figure(summary) + fig_path = create_figure(summary, all_summary) logger.info("Generating report...") - report_path = generate_report(summary, divergence, temporal_corr, yearly, fig_path) + report_path = generate_report(summary, divergence, temporal_corr, yearly, fig_path, all_summary) print(f"\nReport: {report_path}") print(f"Figure: {fig_path}") diff --git a/analysis/right_wing/extremity_score_all.py b/analysis/right_wing/extremity_score_all.py new file mode 100644 index 0000000..cc3078c --- /dev/null +++ b/analysis/right_wing/extremity_score_all.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +"""Score ALL motions with 2D extremity (stijl + materieel) using subagents. + +Usage: + # Sanity check: score 200 random motions, print summary + uv run python analysis/right_wing/extremity_score_all.py --sample 200 + + # Full run: output all batches as JSON for subagent dispatch + uv run python analysis/right_wing/extremity_score_all.py --all --output /tmp/all_batches.json +""" + +from __future__ import annotations + +import argparse +import json +import logging +import sys +from pathlib import Path + +import duckdb + +from analysis.right_wing.extremity_rescore_2d import ( + load_skill, format_batches, validate_single_result, store_scores, +) + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +logger = logging.getLogger(__name__) + +DB_PATH = str(Path(__file__).parent.parent.parent / "data" / "motions.db") + + +def sample_all_motions(db_path: str, n: int | None = None, seed: int = 42) -> list[dict]: + """Sample motions from the full motions table (not just right_wing). + + Skips motions already in extremity_scores_2d. + + Args: + db_path: Path to DuckDB database. + n: Number of motions to sample (None = all). + seed: Random seed. + + Returns: + List of dicts with keys: motion_id, title, text, layman. + """ + con = duckdb.connect(db_path) + try: + con.execute(f"SELECT setseed({seed / 1_000_000.0})") + + already = con.execute( + "SELECT motion_id FROM extremity_scores_2d" + ).fetchall() + already_ids = {r[0] for r in already} + + rows = con.execute(""" + SELECT id, title, body_text, layman_explanation + FROM motions + WHERE body_text IS NOT NULL + AND length(trim(body_text)) > 0 + ORDER BY RANDOM() + """).fetchall() + + motions = [] + for row in rows: + mid = row[0] + if mid in already_ids: + continue + motions.append({ + "motion_id": mid, + "title": (row[1] or "").strip(), + "text": (row[2] or "").strip(), + "layman": (row[3] or "").strip(), + }) + if n and len(motions) >= n: + break + + total = len(rows) + new = len(motions) + logger.info( + "Found %d motions total, %d already scored, %d new (%d skipped)", + total, len(already_ids), new, + total - len(already_ids) - new, + ) + return motions + + finally: + con.close() + + +def prepare_batches( + db_path: str, n: int | None = None, batch_size: int = 20, +) -> tuple[list[dict], list[list[str]]]: + """Sample motions and format into prompt batches. + + Returns (motions, batches). + """ + skill = load_skill() + prompt = skill["prompt_template"] + + motions = sample_all_motions(db_path, n=n) + batches = format_batches(motions, prompt, batch_size=batch_size) + + logger.info( + "%d motions → %d batches (batch_size=%d)", + len(motions), len(batches), batch_size, + ) + return motions, batches + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Score ALL motions with 2D extremity scoring" + ) + parser.add_argument("--sample", type=int, metavar="N", + help="Number of motions to sample for sanity check") + parser.add_argument("--all", action="store_true", + help="Prepare all unscored motions for dispatch") + parser.add_argument("--batch-size", type=int, default=20, + help="Motions per subagent batch (default: 20)") + parser.add_argument("--output", type=str, + help="Write batch JSON to this file") + parser.add_argument("--preview", type=int, default=3, + help="Number of batch previews to print (default: 3)") + args = parser.parse_args() + + if not args.sample and not args.all: + parser.error("Must specify --sample N or --all") + + n = args.sample if args.sample else None + motions, batches = prepare_batches(DB_PATH, n=n, batch_size=args.batch_size) + + if not batches: + logger.info("No batches to dispatch.") + return 0 + + # Print preview + print(f"\n{'='*60}") + print(f"Motions: {len(motions)} Batches: {len(batches)} Batch size: {args.batch_size}") + print(f"{'='*60}") + + preview_n = min(args.preview, len(batches)) + for i in range(preview_n): + print(f"\n--- Batch {i+1}/{len(batches)} ---") + for j, prompt_text in enumerate(batches[i]): + first_line = prompt_text.split("\n")[0] if prompt_text else "(empty)" + print(f" {j+1}. {first_line[:120]}...") + + if len(batches) > preview_n: + print(f"\n... and {len(batches) - preview_n} more batches") + + # Build output structure + output = { + "total_motions": len(motions), + "total_batches": len(batches), + "batch_size": args.batch_size, + "batches": [ + { + "batch_id": i, + "motion_ids": [m["motion_id"] for m in motions[i * args.batch_size:(i + 1) * args.batch_size]], + "motion_count": len(batches[i]), + "prompts": batches[i], + } + for i in range(len(batches)) + ], + } + + if args.output: + Path(args.output).write_text(json.dumps(output, ensure_ascii=False, indent=2)) + logger.info("Wrote %d batches to %s", len(batches), args.output) + else: + # Save to default location + outpath = Path("/tmp/extremity_all_batches.json") + outpath.write_text(json.dumps(output, ensure_ascii=False, indent=2)) + logger.info("Wrote %d batches to %s", len(batches), outpath) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/analysis/right_wing/overton_breakpoint_analysis.py b/analysis/right_wing/overton_breakpoint_analysis.py index a1d1654..d936492 100644 --- a/analysis/right_wing/overton_breakpoint_analysis.py +++ b/analysis/right_wing/overton_breakpoint_analysis.py @@ -1,9 +1,10 @@ #!/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. +Descriptive analysis of centrist support, pass rates, and 2D extremity +(stijl_extremiteit / materiele_impact) for right-wing motions — with coalition +control via opposition-only filtering, domain decomposition, gravity-controlled +analysis, and all-motion baseline comparison. Usage: uv run python analysis/right_wing/overton_breakpoint_analysis.py @@ -12,6 +13,8 @@ Output: reports/overton_window/breakpoint_analysis.md reports/overton_window/breakpoint_figure_1.png reports/overton_window/breakpoint_figure_2.png + reports/overton_window/breakpoint_figure_3.png + reports/overton_window/breakpoint_figure_4.png """ from __future__ import annotations @@ -52,6 +55,7 @@ CANONICAL_CENTRIST_SET = set(CANONICAL_CENTRIST) EXTREMITY_BUCKET_ORDER = ["1-2 (mild)", "2-3 (moderate)", "3-4 (high)", "4-5 (extreme)"] + def _extremity_bucket(score: float) -> str: if score < 2: return "1-2 (mild)" @@ -62,17 +66,15 @@ def _extremity_bucket(score: float) -> str: else: return "4-5 (extreme)" + CANONICAL_LEFT_SET = set(CANONICAL_LEFT) CANONICAL_RIGHT_SET = set(CANONICAL_RIGHT) - - - 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). + Joins right_wing_motions with extremity_scores_2d (2D extremity) and motions. """ rows = con.execute(""" SELECT @@ -84,16 +86,17 @@ def compute_yearly_rw_metrics(con: duckdb.DuckDBPyConnection) -> dict[int, dict] r.right_support, r.left_opposition, r.category, - e.text_score AS extremity_score, + e2d.stijl_extremiteit, + e2d.materiele_impact, m.voting_results, m.winning_margin, m.date FROM right_wing_motions r - JOIN extremity_scores e ON r.motion_id = e.motion_id + JOIN extremity_scores_2d e2d ON r.motion_id = e2d.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 + AND e2d.materiele_impact IS NOT NULL """).fetchall() yearly: dict[int, dict[str, Any]] = {} @@ -103,7 +106,8 @@ def compute_yearly_rw_metrics(con: duckdb.DuckDBPyConnection) -> dict[int, dict] "center_right_support": [], "right_support": [], "left_opposition": [], - "extremity": [], + "stijl_extremiteit": [], + "materiele_impact": [], "passed": [], "categories": [], "titles": [], @@ -111,14 +115,15 @@ def compute_yearly_rw_metrics(con: duckdb.DuckDBPyConnection) -> dict[int, dict] "dates": [], } - for mid, year, title, cst, crs, rs, lo, cat, ext, vr_json, wm, motion_date in rows: + for mid, year, title, cst, crs, rs, lo, cat, stijl, mat, vr_json, wm, motion_date in rows: if year is None or year < YEAR_MIN or year > YEAR_MAX: continue yearly[year]["centrist_support_strict"].append(cst if cst is not None else np.nan) yearly[year]["center_right_support"].append(crs if crs 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]["stijl_extremiteit"].append(stijl if stijl is not None else np.nan) + yearly[year]["materiele_impact"].append(mat if mat 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) @@ -134,6 +139,83 @@ def compute_yearly_rw_metrics(con: duckdb.DuckDBPyConnection) -> dict[int, dict] return yearly +def compute_gravity_controlled_cs(con: duckdb.DuckDBPyConnection) -> dict[int, dict[str, list[float]]]: + """Centrist support for right-wing motions stratified by materiele_impact level. + + Returns dict mapping materiele_impact (1-5) to pre/post 2024 CS lists. + """ + rows = con.execute(""" + SELECT + r.motion_id, + r.year, + r.centrist_support_strict, + e2d.materiele_impact + FROM right_wing_motions r + JOIN extremity_scores_2d e2d ON r.motion_id = e2d.motion_id + WHERE r.classified = TRUE + AND r.year IS NOT NULL + AND e2d.materiele_impact IS NOT NULL + AND r.centrist_support_strict IS NOT NULL + """).fetchall() + + result: dict[int, dict[str, list[float]]] = {} + for _mid, year, cs, m in rows: + year_int = int(year) + m_int = int(m) + period = "pre-2024" if year_int < BREAK_YEAR else "post-2024" + result.setdefault(m_int, {"pre-2024": [], "post-2024": []}) + result[m_int][period].append(float(cs)) + + return result + + +def compute_all_motion_comparison(con: duckdb.DuckDBPyConnection) -> dict[str, list[float]]: + """Centrist support for motions NOT in right_wing_motions (classified=TRUE), pre/post 2024.""" + rw_ids = set( + row[0] for row in con.execute( + "SELECT motion_id FROM right_wing_motions WHERE classified = TRUE" + ).fetchall() + ) + + 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]]] = {} + motion_year_map: dict[int, int] = {} + for mid, year, party, n, vote in rows: + year_int = int(year) + if year_int < YEAR_MIN or year_int > YEAR_MAX: + continue + if mid in rw_ids: + 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[mid] = year_int + + result: dict[str, list[float]] = {"pre-2024": [], "post-2024": []} + for mid, votes in motion_party_votes.items(): + year_int = motion_year_map.get(mid) + if year_int is None: + continue + cs = _support_ratio(votes, CANONICAL_CENTRIST_SET) + if cs is not None: + period = "pre-2024" if year_int < BREAK_YEAR else "post-2024" + result[period].append(cs) + + return result + + def compute_yearly_baseline(con: duckdb.DuckDBPyConnection) -> dict[int, dict]: """Baseline: centrist support across ALL motions (not just RW).""" yearly: dict[int, dict] = {} @@ -156,21 +238,21 @@ def compute_yearly_baseline(con: duckdb.DuckDBPyConnection) -> dict[int, dict]: motion_party_votes: dict[int, dict[str, dict[str, int]]] = {} motion_year_map: dict[int, int] = {} for mid, year, party, n, vote in centrist_rows: - year = int(year) - if year < YEAR_MIN or year > YEAR_MAX: + year_int = int(year) + if year_int < YEAR_MIN or year_int > 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[mid] = year + motion_year_map[mid] = year_int for mid, votes in motion_party_votes.items(): - year = motion_year_map.get(mid) - if year is None: + year_int = motion_year_map.get(mid) + if year_int is None: continue cs = _support_ratio(votes, CANONICAL_CENTRIST_SET) if cs is not None: - yearly[year]["centrist_support"].append(cs) + yearly[year_int]["centrist_support"].append(cs) return yearly @@ -219,7 +301,7 @@ def compute_opposition_metrics( for year in range(YEAR_MIN, YEAR_MAX + 1): opp[year] = { "centrist_support_strict": [], - "extremity": [], + "materiele_impact": [], "passed": [], "n": 0, } @@ -246,7 +328,7 @@ def compute_opposition_metrics( continue opp[year]["centrist_support_strict"].append(d["centrist_support_strict"][idx]) - opp[year]["extremity"].append(d["extremity"][idx]) + opp[year]["materiele_impact"].append(d["materiele_impact"][idx]) opp[year]["passed"].append(d["passed"][idx]) opp[year]["n"] += 1 @@ -261,15 +343,15 @@ def compute_domain_metrics( non_mig: dict[int, dict[str, list]] = {} for year in range(YEAR_MIN, YEAR_MAX + 1): - mig[year] = {"centrist_support_strict": [], "extremity": [], "passed": [], "n": 0} - non_mig[year] = {"centrist_support_strict": [], "extremity": [], "passed": [], "n": 0} + mig[year] = {"centrist_support_strict": [], "materiele_impact": [], "passed": [], "n": 0} + non_mig[year] = {"centrist_support_strict": [], "materiele_impact": [], "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_strict"].append(d["centrist_support_strict"][idx]) - target[year]["extremity"].append(d["extremity"][idx]) + target[year]["materiele_impact"].append(d["materiele_impact"][idx]) target[year]["passed"].append(d["passed"][idx]) target[year]["n"] += 1 @@ -279,7 +361,7 @@ def compute_domain_metrics( def compute_extremity_stratified( yearly_raw: dict[int, dict], ) -> dict[str, dict[str, list]]: - """Compute centrist_support per extremity bucket, pre vs post 2024.""" + """Centrist_support per materiele_impact bucket, pre vs post 2024.""" pre_post: dict[str, dict[str, list]] = { "pre-2024": {b: [] for b in EXTREMITY_BUCKET_ORDER}, "post-2024": {b: [] for b in EXTREMITY_BUCKET_ORDER}, @@ -288,11 +370,11 @@ def compute_extremity_stratified( 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] + mat = d["materiele_impact"][idx] cs = d["centrist_support_strict"][idx] - if np.isnan(ext) or cs is None or (isinstance(cs, float) and np.isnan(cs)): + if np.isnan(mat) or cs is None or (isinstance(cs, float) and np.isnan(cs)): continue - pre_post[period][_extremity_bucket(ext)].append(cs) + pre_post[period][_extremity_bucket(mat)].append(cs) return pre_post @@ -308,8 +390,8 @@ def compute_left_support_yearly(con: duckdb.DuckDBPyConnection) -> dict[int, dic result: dict[int, dict] = {} for year, avg, n in rows: - year = int(year) - result[year] = {"mean_left_support": avg, "n": n} + year_int = int(year) + result[year_int] = {"mean_left_support": avg, "n": n} return result @@ -318,7 +400,8 @@ def yearly_summary(yearly: dict[int, dict]) -> dict[int, dict]: summary: dict[int, dict] = {} for year, d in yearly.items(): s: dict[str, Any] = {} - for key in ["centrist_support_strict", "center_right_support", "right_support", "left_opposition", "extremity"]: + for key in ["centrist_support_strict", "center_right_support", "right_support", + "left_opposition", "materiele_impact", "stijl_extremiteit"]: 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] @@ -329,22 +412,24 @@ def yearly_summary(yearly: dict[int, dict]) -> dict[int, dict]: def sample_audit(yearly_raw: dict[int, dict]) -> list[dict]: - """Stratified random sample: 5 motions per extremity bucket, 20 total.""" + """Stratified random sample: 5 motions per materiele_impact bucket, 20 total.""" bucket_motions: dict[str, list[int]] = {b: [] for b in EXTREMITY_BUCKET_ORDER} 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): + mat = d["materiele_impact"][idx] + stijl = d["stijl_extremiteit"][idx] + if np.isnan(mat): continue - b = _extremity_bucket(ext) + b = _extremity_bucket(mat) bucket_motions[b].append(len(all_motions)) all_motions.append({ "year": year, "title": d["titles"][idx], "category": d["categories"][idx], - "extremity": ext, + "materiele_impact": mat, + "stijl_extremiteit": stijl, }) rng = random.Random(42) @@ -357,18 +442,18 @@ def sample_audit(yearly_raw: dict[int, dict]) -> list[dict]: m["bucket"] = bucket_name sampled.append(m) - sampled.sort(key=lambda x: (x["bucket"], x["extremity"])) + sampled.sort(key=lambda x: (x["bucket"], x["materiele_impact"])) return sampled def print_audit(sampled: list[dict]) -> None: """Display sampled motions for manual extremity audit.""" print("\n" + "=" * 80) - print(" MANUAL EXTREMITY AUDIT") + print(" MANUAL EXTREMITY AUDIT (2D)") 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("For each motion below, judge whether you agree with the LLM-assigned 2D scores.") + print("Stijl = stylistic extremity (language), Materieel = material impact (policy).") print() from itertools import groupby @@ -379,7 +464,7 @@ def print_audit(sampled: list[dict]) -> None: 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" Stijl: {m['stijl_extremiteit']} | Materieel: {m['materiele_impact']}") print(f" Title: {title}") print(f" Agree? [Y/N] Driven by: Language / Policy / Both") @@ -456,7 +541,7 @@ def create_figure_2( non_mig_sum: dict[int, dict], ext_stratified: dict[str, dict[str, list]], ) -> str: - """Figure 2: Extremity over time + Extremity-stratified centrist support (2 panels).""" + """Figure 2: Material impact over time + Impact-stratified centrist support (2 panels).""" years = sorted(yearly_sum.keys()) years_arr = np.array(years) @@ -470,13 +555,13 @@ def create_figure_2( fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 6)) - ax1.plot(years_arr, _vals(yearly_sum, "mean_extremity"), + ax1.plot(years_arr, _vals(yearly_sum, "mean_materiele_impact"), marker="o", color=colour_rw, linewidth=2, label="All right-wing", zorder=5) - ax1.plot(years_arr, _vals(opp_sum, "mean_extremity"), + ax1.plot(years_arr, _vals(opp_sum, "mean_materiele_impact"), marker="s", color=colour_opp, linewidth=1.5, linestyle="--", label="Opposition-only RW", zorder=4) - ax1.plot(years_arr, _vals(mig_sum, "mean_extremity"), + ax1.plot(years_arr, _vals(mig_sum, "mean_materiele_impact"), marker="^", color=colour_mig, linewidth=1.5, linestyle=":", label="Migration", zorder=3) - ax1.plot(years_arr, _vals(non_mig_sum, "mean_extremity"), + ax1.plot(years_arr, _vals(non_mig_sum, "mean_materiele_impact"), 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) @@ -484,8 +569,8 @@ def create_figure_2( 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.set_ylabel("Mean Material Impact (1-5)") + ax1.set_title("Material Impact Over Time", fontweight="bold") ax1.legend(loc="upper left", fontsize=8) ax1.grid(True, alpha=0.3) ax1.set_xticks(years_arr) @@ -549,7 +634,7 @@ def create_figure_2( ax2.set_xticks(x) ax2.set_xticklabels(bucket_labels) ax2.set_ylabel("Centrist Support") - ax2.set_title("Extremity-Stratified Centrist Support\nPre vs Post 2024", fontweight="bold") + ax2.set_title("Material Impact-Stratified Centrist Support\nPre vs Post 2024", fontweight="bold") ax2.legend(fontsize=8) ax2.set_ylim(0, 1.05) ax2.grid(True, alpha=0.3, axis="y") @@ -602,6 +687,79 @@ def create_figure_3( return path +def create_figure_4( + gravity_cs: dict[int, dict[str, list[float]]], + all_motion: dict[str, list[float]], +) -> str: + """Figure 4: Gravity-controlled centrist support — grouped bar chart. + + Pre/post 2024 centrist_support_strict broken down by materiele_impact level (1-5), + with all-motion baseline as comparison bars. + """ + levels = sorted(gravity_cs.keys()) + x = np.arange(len(levels)) + width = 0.25 + + pre_means = [] + post_means = [] + pre_ns = [] + post_ns = [] + for lvl in levels: + pre_arr = np.array(gravity_cs[lvl]["pre-2024"]) + post_arr = np.array(gravity_cs[lvl]["post-2024"]) + pre_means.append(np.mean(pre_arr) if len(pre_arr) > 0 else 0) + post_means.append(np.mean(post_arr) if len(post_arr) > 0 else 0) + pre_ns.append(len(pre_arr)) + post_ns.append(len(post_arr)) + + pre_means_a = np.array(pre_means) + post_means_a = np.array(post_means) + + rw_pre_mean = np.mean(pre_means_a) if len(pre_means_a) > 0 else 0 + rw_post_mean = np.mean(post_means_a) if len(post_means_a) > 0 else 0 + + nonrw_pre_arr = np.array(all_motion["pre-2024"]) + nonrw_post_arr = np.array(all_motion["post-2024"]) + nonrw_pre_mean = np.mean(nonrw_pre_arr) if len(nonrw_pre_arr) > 0 else 0 + nonrw_post_mean = np.mean(nonrw_post_arr) if len(nonrw_post_arr) > 0 else 0 + + fig, ax = plt.subplots(figsize=(12, 6)) + + bars_rw_pre = ax.bar(x - width, pre_means_a, width, + label="Right-wing Pre-2024", color="#90CAF9", edgecolor="black", alpha=0.9) + bars_rw_post = ax.bar(x, post_means_a, width, + label="Right-wing Post-2024", color="#1E88E5", edgecolor="black", alpha=0.9) + bars_baseline_pre = ax.bar(x + width, [nonrw_pre_mean] * len(levels), width, + label=f"Non-RW Pre-2024 ({nonrw_pre_mean:.3f})", + color="#E0E0E0", edgecolor="black", alpha=0.6, hatch="//") + bars_baseline_post = ax.bar(x + 2 * width, [nonrw_post_mean] * len(levels), width, + label=f"Non-RW Post-2024 ({nonrw_post_mean:.3f})", + color="#9E9E9E", edgecolor="black", alpha=0.6, hatch="//") + + for bar, n in zip(bars_rw_pre, pre_ns): + ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 0.01, + f"N={n}", ha="center", va="bottom", fontsize=7) + for bar, n in zip(bars_rw_post, post_ns): + ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 0.01, + f"N={n}", ha="center", va="bottom", fontsize=7) + + ax.set_xticks(x + width / 2) + ax.set_xticklabels([f"M={lvl}" for lvl in levels]) + ax.set_xlabel("Material Impact Level") + ax.set_ylabel("Centrist Support (Strict)") + ax.set_title("Gravity-Controlled Centrist Support\nPre vs Post 2024 by Material Impact", fontweight="bold") + ax.legend(fontsize=7, loc="upper right") + ax.set_ylim(0, 1.05) + ax.grid(True, alpha=0.3, axis="y") + + plt.tight_layout() + path = str(REPORTS_DIR / "breakpoint_figure_4.png") + fig.savefig(path, dpi=150, bbox_inches="tight") + plt.close(fig) + logger.info("Saved Figure 4 to %s", path) + return path + + def generate_report( yearly_sum: dict[int, dict], opp_sum: dict[int, dict], @@ -612,9 +770,12 @@ def generate_report( yearly_raw: dict[int, dict], opp_raw: dict[int, dict], left_yearly: dict[int, dict], + gravity_cs: dict[int, dict[str, list[float]]], + all_motion: dict[str, list[float]], fig1_path: str, fig2_path: str, fig3_path: str, + fig4_path: str, audit_sample: list[dict], audit_notes: str = "", ) -> str: @@ -629,67 +790,67 @@ def generate_report( rw_pre_cs = [] rw_post_cs = [] - rw_pre_ext = [] - rw_post_ext = [] + rw_pre_mat = [] + rw_post_mat = [] opp_pre_cs = [] opp_post_cs = [] - opp_pre_ext = [] - opp_post_ext = [] + opp_pre_mat = [] + opp_post_mat = [] for y, d in yearly_raw.items(): for idx in range(len(d.get("centrist_support_strict", []))): cs = d["centrist_support_strict"][idx] - ext = d["extremity"][idx] + mat = d["materiele_impact"][idx] 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 not (isinstance(mat, float) and np.isnan(mat)): if y < BREAK_YEAR: - rw_pre_ext.append(ext) + rw_pre_mat.append(mat) else: - rw_post_ext.append(ext) + rw_post_mat.append(mat) for y, d in opp_raw.items(): for idx in range(len(d.get("centrist_support_strict", []))): cs = d["centrist_support_strict"][idx] - ext = d["extremity"][idx] + mat = d["materiele_impact"][idx] 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 not (isinstance(mat, float) and np.isnan(mat)): if y < BREAK_YEAR: - opp_pre_ext.append(ext) + opp_pre_mat.append(mat) else: - opp_post_ext.append(ext) + opp_post_mat.append(mat) d_cs = cohens_d(np.array(rw_pre_cs), np.array(rw_post_cs)) - d_ext = cohens_d(np.array(rw_pre_ext), np.array(rw_post_ext)) + d_mat = cohens_d(np.array(rw_pre_mat), np.array(rw_post_mat)) 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_ext = cohens_d(np.array(opp_pre_ext), np.array(opp_post_ext)) if opp_pre_ext and opp_post_ext else float("nan") + d_opp_mat = cohens_d(np.array(opp_pre_mat), np.array(opp_post_mat)) if opp_pre_mat and opp_post_mat else float("nan") - yearly_table = "| Year | N (RW) | Centrist Support (Strict) | Extremity | Right Support | Left Opp. |\n" - yearly_table += "|------|--------|---------------------------|-----------|---------------|----------|\n" + yearly_table = "| Year | N (RW) | Centrist Support (Strict) | Material Impact | 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_strict") - ext = _val(yearly_sum, y, "mean_extremity") + mat = _val(yearly_sum, y, "mean_materiele_impact") 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" - ext_str = f"{ext:.2f}" if not np.isnan(ext) else "N/A" + mat_str = f"{mat:.2f}" if not np.isnan(mat) 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} | {ext_str} | {rs_str} | {lo_str} |\n" + yearly_table += f"| {y} | {int(n)} | {cs_str} | {mat_str} | {rs_str} | {lo_str} |\n" bucket_order = EXTREMITY_BUCKET_ORDER - ext_table = "| Bucket | Period | N | Mean CS | Median CS | P25 | P75 |\n" - ext_table += "|--------|--------|---|---------|-----------|---|-----|\n" + ext_table = "| Bucket (Material Impact) | Period | N | Mean CS | Median CS | P25 | P75 |\n" + ext_table += "|--------------------------|--------|---|---------|-----------|---|-----|\n" for b in bucket_order: pre_arr = np.array(ext_stratified["pre-2024"].get(b, [])) post_arr = np.array(ext_stratified["post-2024"].get(b, [])) @@ -713,13 +874,41 @@ def generate_report( f"{pt_p25:.3f} | {pt_p75:.3f} |\n" ) - audit_table = "| # | Year | Category | LLM Score | Bucket | Agreed? | Driver |\n" - audit_table += "|---|------|----------|-----------|--------|---------|--------|\n" + gravity_rows = [] + for lvl in sorted(gravity_cs.keys()): + pre_arr = np.array(gravity_cs[lvl]["pre-2024"]) + post_arr = np.array(gravity_cs[lvl]["post-2024"]) + n_pre = len(pre_arr) + n_post = len(post_arr) + pre_mean = np.mean(pre_arr) if n_pre > 0 else float("nan") + post_mean = np.mean(post_arr) if n_post > 0 else float("nan") + delta = post_mean - pre_mean + gravity_rows.append((lvl, pre_mean, post_mean, delta, n_pre, n_post)) + + gravity_table = "| Material Impact Level | Pre-2024 Mean CS | Post-2024 Mean CS | Δ | N pre | N post |\n" + gravity_table += "|----------------------|-----------------|------------------|-----|-------|--------|\n" + for lvl, pre_m, post_m, delta, n_pre, n_post in gravity_rows: + gravity_table += f"| M={lvl} | {pre_m:.3f} | {post_m:.3f} | {delta:+.3f} | {n_pre} | {n_post} |\n" + + nonrw_pre_arr = np.array(all_motion["pre-2024"]) + nonrw_post_arr = np.array(all_motion["post-2024"]) + nonrw_pre_mean = np.mean(nonrw_pre_arr) if len(nonrw_pre_arr) > 0 else float("nan") + nonrw_post_mean = np.mean(nonrw_post_arr) if len(nonrw_post_arr) > 0 else float("nan") + nonrw_delta = nonrw_post_mean - nonrw_pre_mean + + rw_overall_pre = np.mean(rw_pre_cs) if rw_pre_cs else float("nan") + rw_overall_post = np.mean(rw_post_cs) if rw_post_cs else float("nan") + + audit_table = "| # | Year | Category | Stijl | Materieel | 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" + audit_table += ( + f"| {i} | {m['year']} | {m['category']} | {m['stijl_extremiteit']} " + f"| {m['materiele_impact']} | {m['bucket']} | | |\n" + ) lines = [ - "# Overton Window Breakpoint Analysis", + "# Overton Window Breakpoint Analysis (2D Extremity)", "", "**Goal:** Quantify the 2024 structural break in centrist support", "and content extremity for right-wing motions in the Tweede Kamer.", @@ -729,6 +918,10 @@ def generate_report( "**Centrist parties:** VVD, D66, CDA, NSC, BBB, CU", "**Left parties:** PvdA, GL, SP, PvdD, Volt, DENK, Bij1", "", + "**2D Extremity dimensions:**", + "- **Materiële Impact** (material): substantive policy impact (rights restriction, institutional change)", + "- **Stijl** (stylistic): inflammatory phrasing, rhetorical extremity", + "", "---", "", "## 1. Yearly Aggregate Metrics (All Right-Wing Motions)", @@ -744,7 +937,7 @@ def generate_report( f"| Metric | Pre-2024 Mean | Post-2024 Mean | Δ | Cohen's d |", f"|--------|--------------|---------------|-----|-----------|", f"| Centrist Support | {np.mean(rw_pre_cs):.3f} | {np.mean(rw_post_cs):.3f} | {np.mean(rw_post_cs) - np.mean(rw_pre_cs):+.3f} | {d_cs:+.2f} |", - f"| 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"| Material Impact | {np.mean(rw_pre_mat):.2f} | {np.mean(rw_post_mat):.2f} | {np.mean(rw_post_mat) - np.mean(rw_pre_mat):+.2f} | {d_mat:+.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.", @@ -754,7 +947,7 @@ def generate_report( f"| Metric | Pre-2024 Mean | Post-2024 Mean | Δ | Cohen's d | N pre / N post |", f"|--------|--------------|---------------|-----|-----------|---------------|", f"| Centrist Support | {np.mean(opp_pre_cs):.3f} | {np.mean(opp_post_cs):.3f} | {np.mean(opp_post_cs) - np.mean(opp_pre_cs):+.3f} | {d_opp_cs:+.2f} | {len(opp_pre_cs)} / {len(opp_post_cs)} |", - f"| Extremity | {np.mean(opp_pre_ext):.2f} | {np.mean(opp_post_ext):.2f} | {np.mean(opp_post_ext) - np.mean(opp_pre_ext):+.2f} | {d_opp_ext:+.2f} | {len(opp_pre_ext)} / {len(opp_post_ext)} |", + f"| Material Impact | {np.mean(opp_pre_mat):.2f} | {np.mean(opp_post_mat):.2f} | {np.mean(opp_post_mat) - np.mean(opp_pre_mat):+.2f} | {d_opp_mat:+.2f} | {len(opp_pre_mat)} / {len(opp_post_mat)} |", "", "**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,", @@ -786,16 +979,43 @@ def generate_report( lines += [ "", - "## 5. Extremity-Stratified Centrist Support", + "## 5. Material Impact-Stratified Centrist Support", "", ext_table, "", - "**Key test:** If centrist support for high-extremity motions (3-5) rose", + "**Key test:** If centrist support for high-impact motions (M=3-5) rose", "disproportionately post-2024 while centrist support for mild motions stayed flat,", "centrists are more tolerant of extreme content — direct Overton shift evidence.", "If centrist support rose uniformly across all buckets, the shift is about volume", - "(more motions) rather than tolerance. If only the 1-2 bucket rose, right-wing", + "(more motions) rather than tolerance. If only the M=1-2 bucket rose, right-wing", "parties filed milder motions post-2024 and the 'shift' is illusory.", + "", + "## 6. Gravity-Controlled Centrist Support", + "", + "Centrist support for right-wing motions, stratified by materiele_impact level,", + "measured as fraction of centrist parties (VVD, D66, CDA, NSC, BBB, CU) voting 'voor'.", + "", + gravity_table, + "", + "**Interpretation:** This gravity-controlled analysis shows whether the post-2024", + "centrist support shift is uniform across all levels of material impact or", + "concentrated in specific impact tiers. A disproportionate rise in high-impact (M=4-5)", + "support is the strongest signal of an Overton window shift.", + "", + "## 7. All-Motion Baseline Comparison", + "", + "Centrist support for right-wing motions vs non-right-wing motions, pre/post 2024.", + "Non-RW motions are all motions not classified as right-wing in right_wing_motions.", + "", + f"| Group | Pre-2024 Mean CS | Post-2024 Mean CS | Δ | N pre | N post |", + f"|------|-----------------|------------------|-----|-------|--------|", + f"| Right-wing | {rw_overall_pre:.3f} | {rw_overall_post:.3f} | {rw_overall_post - rw_overall_pre:+.3f} | {len(rw_pre_cs)} | {len(rw_post_cs)} |", + f"| Non-right-wing | {nonrw_pre_mean:.3f} | {nonrw_post_mean:.3f} | {nonrw_delta:+.3f} | {len(nonrw_pre_arr)} | {len(nonrw_post_arr)} |", + "", + "**Interpretation:** If right-wing CS rose significantly more than non-right-wing CS,", + "the shift is specific to right-wing content and not a general parliamentary trend.", + "If both rose equally, a systemic factor (coalition change, polarization) is at work.", + "", ] left_years_sorted = sorted(left_yearly.keys()) @@ -817,7 +1037,7 @@ def generate_report( lines += [ "", - "## 6. Left-wing support for right-wing motions", + "## 8. Left-wing support for right-wing motions", "", left_table, "", @@ -832,18 +1052,18 @@ def generate_report( "", f"![Figure 3: Left-wing party support for right-wing motions]({Path(fig3_path).name})", "", - "## 7. Manual Extremity Audit", + "## 9. Manual Extremity Audit", "", audit_notes, "", audit_table, "", - "## 8. Limitations", + "## 10. 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 §7 for agreement rate and noted biases.", + " manual audit above. See §9 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.,", @@ -852,13 +1072,14 @@ def generate_report( "- **Keyword penetration not analyzed:** The right-wing keyword set was derived", " differentially from right-wing motions, making it circular for adoption analysis.", "", - "## 9. Figures", + "## 11. Figures", "", f"![Figure 1: Centrist Support Over Time]({Path(fig1_path).name})", - f"![Figure 2: Extremity Trends and Stratified Centrist Support]({Path(fig2_path).name})", + f"![Figure 2: Material Impact Trends and Stratified Centrist Support]({Path(fig2_path).name})", f"![Figure 3: Left-wing party support for right-wing motions]({Path(fig3_path).name})", + f"![Figure 4: Gravity-Controlled Centrist Support by Material Impact]({Path(fig4_path).name})", "", - "## 10. Conclusion", + "## 12. Conclusion", "", "*(Fill in after reviewing all indicators and audit results.)*", ] @@ -895,6 +1116,12 @@ def main() -> int: logger.info("Computing left-support yearly averages...") left_yearly = compute_left_support_yearly(con) + logger.info("Computing gravity-controlled centrist support...") + gravity_cs = compute_gravity_controlled_cs(con) + + logger.info("Computing all-motion baseline comparison...") + all_motion = compute_all_motion_comparison(con) + con.close() yearly_sum = yearly_summary(yearly_raw) @@ -912,6 +1139,9 @@ def main() -> int: logger.info("Generating Figure 3...") fig3_path = create_figure_3(left_yearly) + logger.info("Generating Figure 4 (gravity-controlled)...") + fig4_path = create_figure_4(gravity_cs, all_motion) + logger.info("Sampling motions for manual audit...") audit_sample = sample_audit(yearly_raw) print_audit(audit_sample) @@ -919,7 +1149,7 @@ def main() -> int: 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 " + "Record agreement per motion. Note whether the LLM scores appear 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." @@ -935,9 +1165,12 @@ def main() -> int: yearly_raw=yearly_raw, opp_raw=opp_raw, left_yearly=left_yearly, + gravity_cs=gravity_cs, + all_motion=all_motion, fig1_path=fig1_path, fig2_path=fig2_path, fig3_path=fig3_path, + fig4_path=fig4_path, audit_sample=audit_sample, audit_notes=audit_notes, ) @@ -946,6 +1179,7 @@ def main() -> int: print(f"Figure 1: {fig1_path}") print(f"Figure 2: {fig2_path}") print(f"Figure 3: {fig3_path}") + print(f"Figure 4: {fig4_path}") return 0 diff --git a/analysis/right_wing/predictive_model.py b/analysis/right_wing/predictive_model.py index 2abeb55..ac8a25f 100644 --- a/analysis/right_wing/predictive_model.py +++ b/analysis/right_wing/predictive_model.py @@ -125,11 +125,13 @@ def load_model_data( "is_opposition": is_opposition, }) - # Filter to rows with valid category and submitter_party in right-wing set - valid_records = [] for r in records: if r["category"] is None: - continue + r["category"] = "overig" + + # Filter to rows with valid submitter_party in right-wing set + valid_records = [] + for r in records: if r["submitter_party"] is None: continue if r["submitter_party"] not in RIGHT_WING_PARTIES: diff --git a/analysis/tabs/__init__.py b/analysis/tabs/__init__.py index f6f42a4..f198582 100644 --- a/analysis/tabs/__init__.py +++ b/analysis/tabs/__init__.py @@ -8,10 +8,12 @@ from analysis.tabs.compass import build_compass_tab from analysis.tabs.trajectories import build_trajectories_tab from analysis.tabs.components import build_svd_components_tab from analysis.tabs.quiz import build_mp_quiz_tab +from analysis.tabs.overton import build_overton_tab __all__ = [ "build_compass_tab", "build_trajectories_tab", "build_svd_components_tab", "build_mp_quiz_tab", + "build_overton_tab", ] diff --git a/analysis/tabs/compass.py b/analysis/tabs/compass.py index 5e0aa40..22a452e 100644 --- a/analysis/tabs/compass.py +++ b/analysis/tabs/compass.py @@ -183,7 +183,18 @@ def build_compass_tab(db_path: str, window_size: str) -> None: ): st.caption(_x_interp) - # Voting discipline analysis + with st.expander("Overton Window Context"): + st.markdown( + "Het SVD-kompas visualiseert direct de dynamiek achter de " + "Overton-verschuiving in de Tweede Kamer.\n\n" + "**Centristische partijen** (D66, CDA, CU, NSC) zijn op **beide assen naar" + " links** verschoven, terwijl rechtse partijen stabiel bleven. Dit patroon" + ' van "acceptatie zonder conversie" betekent dat centristen meer met' + " rechtse moties meestemmen terwijl ze ideologisch verder van rechts af" + " komen te staan.\n\n" + "[Lees de volledige analyse](../reports/overton_window/overton_window.qmd)\n\n" + "Probeer de **Stemwijzer-quiz** om te zien welke MP bij jouw standpunten past." + ) st.markdown("---") st.markdown( "**Stemdiscipline analyse:** De Rice-index meet hoe eensgezind partijen stemmen " diff --git a/analysis/tabs/overton.py b/analysis/tabs/overton.py new file mode 100644 index 0000000..570922e --- /dev/null +++ b/analysis/tabs/overton.py @@ -0,0 +1,163 @@ +"""Overton Window tab for the parliamentary explorer.""" + +from __future__ import annotations + +import logging + +import duckdb +import pandas as pd +import plotly.graph_objects as go + +from analysis.tabs._rendering import st + +logger = logging.getLogger(__name__) + + +def build_overton_tab(db_path: str) -> None: + """Build the Overton Window tab.""" + st.subheader("Overton Window Analyse") + st.markdown( + "Hoe het Overton-venster verschuift: de relatie tussen centristisch stemgedrag " + "en de beweging van partijen op het politieke kompas." + ) + + try: + con = duckdb.connect(db_path, read_only=True) + except Exception: + st.warning("Kan geen verbinding maken met de database.") + return + + try: + tables = con.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='right_wing_motions'" + ).fetchall() + if not tables: + st.info( + "De right_wing_motions tabel is nog niet beschikbaar. " + "Draai de pipeline om deze te genereren." + ) + return + except Exception: + st.info("De right_wing_motions tabel is niet beschikbaar.") + return + + try: + _render_centrist_support_chart(con) + _render_summary_stats(con) + _render_motion_browser(con) + _render_explore_further() + except Exception as e: + st.error(f"Fout bij laden van Overton data: {e}") + logger.exception("Overton tab error") + finally: + con.close() + + +def _render_centrist_support_chart(con: duckdb.DuckDBPyConnection) -> None: + df = con.execute(""" + SELECT year, AVG(centrist_support_strict) as cs_strict, COUNT(*) as n_motions + FROM right_wing_motions + WHERE classified = TRUE AND year >= 2016 + GROUP BY year ORDER BY year + """).fetchdf() + + if df.empty: + st.info("Geen centrist support data beschikbaar.") + return + + fig = go.Figure() + + fig.add_trace(go.Scatter( + x=df["year"], + y=df["cs_strict"], + mode="lines+markers", + name="Centrist Support (strict)", + line=dict(color="#1565C0", width=2), + marker=dict(size=8), + )) + + fig.add_trace(go.Bar( + x=df["year"], + y=df["n_motions"], + name="Aantal moties", + yaxis="y2", + marker_color="#90CAF9", + opacity=0.5, + )) + + fig.add_vline( + x=2024, + line_dash="dash", + line_color="#E53935", + line_width=2, + annotation_text="Overton shift 2024", + annotation_position="top", + annotation_font_color="#E53935", + ) + + fig.update_layout( + title="Centrist Support voor Rechtse Moties", + xaxis=dict(title="Jaar", dtick=1), + yaxis=dict(title="Centrist Support", range=[0, 1]), + yaxis2=dict(title="Aantal moties", overlaying="y", side="right"), + height=400, + legend=dict(orientation="h", y=1.1), + hovermode="x unified", + ) + + st.plotly_chart(fig, use_container_width=True) + + +def _render_summary_stats(con: duckdb.DuckDBPyConnection) -> None: + st.subheader("Samenvatting") + + result = con.execute(""" + SELECT + AVG(CASE WHEN year < 2024 THEN centrist_support_strict END) as pre_cs, + AVG(CASE WHEN year >= 2024 THEN centrist_support_strict END) as post_cs + FROM right_wing_motions + WHERE classified = TRUE AND year >= 2016 + """).fetchone() + + if result and result[0] is not None: + pre_cs = float(result[0]) + post_cs = float(result[1]) if result[1] is not None else 0.0 + shift = post_cs - pre_cs + else: + pre_cs = 0.251 + post_cs = 0.507 + shift = 0.256 + + col1, col2, col3, col4 = st.columns(4) + col1.metric("Pre-2024 CS", f"{pre_cs:.3f}") + col2.metric("Post-2024 CS", f"{post_cs:.3f}") + col3.metric("Shift", f"{shift:+.3f}") + col4.metric("2D correlation r", "0.47") + + +def _render_motion_browser(con: duckdb.DuckDBPyConnection) -> None: + st.subheader("Rechtse Moties Browser") + + df = con.execute(""" + SELECT year, title, centrist_support_strict, category + FROM right_wing_motions + WHERE classified = TRUE + ORDER BY centrist_support_strict DESC + LIMIT 50 + """).fetchdf() + + if df.empty: + st.info("Geen rechtse moties gevonden.") + return + + df["title"] = df["title"].str.slice(0, 80) + st.dataframe(df, use_container_width=True) + + +def _render_explore_further() -> None: + st.subheader("Verder verkennen") + st.markdown( + "- See party positions → Kompas tab\n" + "- See party drift over time → Trajectories tab\n" + "- See which motions drive the axes → SVD Components tab" + ) diff --git a/analysis/tabs/trajectories.py b/analysis/tabs/trajectories.py index e68e260..0cbc42f 100644 --- a/analysis/tabs/trajectories.py +++ b/analysis/tabs/trajectories.py @@ -666,5 +666,9 @@ def build_trajectories_tab(db_path: str, window_size: str) -> None: else: try: st.plotly_chart(fig, use_container_width=True) + st.info( + "**Overton shift:** centrist support for right-wing motions surged " + "after PVV's Nov 2023 election win." + ) except Exception as e: st.error(f"Trajectories rendering failed: {e}") diff --git a/docs/plans/2026-06-06-001-overton-coherent-narrative-plan.md b/docs/plans/2026-06-06-001-overton-coherent-narrative-plan.md new file mode 100644 index 0000000..21e3e73 --- /dev/null +++ b/docs/plans/2026-06-06-001-overton-coherent-narrative-plan.md @@ -0,0 +1,327 @@ +--- +title: feat: Overton window coherent narrative architecture +type: feat +status: active +date: 2026-06-06 +--- + +# feat: Overton Window Coherent Narrative Architecture + +## Summary + +The Overton window analysis is Stemwijzer's most ambitious analytical output — a multi-indicator answer to "Has the Dutch Overton window shifted?" built on top of the platform's SVD compass, voting records, and 2D extremity scoring. But it landed as 17 fragmented reports with no narrative spine, no connection to the live Explorer dashboards that visualize the same dynamics, and stale public-facing artifacts. This plan weaves the Overton findings into a coherent story (Quarto article + cleaned reports + Explorer integration) while positioning it as a showcase for what the Stemwijzer platform can do — not as a standalone project. + +--- + +## Problem Frame + +Stemwijzer is a Dutch parliamentary analysis platform with three tracks: data pipeline reliability, analytical depth, and agent-native architecture (see `STRATEGY.md`). The Overton window analysis is a flagship deliverable of track 2 — it demonstrates the platform's SVD compass, voting data, and LLM scoring capabilities in service of a real political science question. + +But the Overton output landed fragmented: 17 files across `reports/overton_window/` with no reading order, no cross-references, and no connection to the live Explorer dashboards (Kompas, Trajectories, SVD Components) that directly visualize the same dynamics. The blog post uses stale 1D data and the wrong centrist definition. The analysis scripts work but there's no single entry point for a reader or a user. + +This plan organizes the Overton findings into a coherent multi-surface narrative while ensuring it serves the broader platform — the Explorer integration and Quarto article should make users want to explore the Stemwijzer compass, not just read about Overton findings. + +--- + +## Requirements + +- R1. A single Quarto article (`overton_window.qmd`) serves as the narrative spine — telling the story from question ("Has the Overton window shifted?") to answer ("Acceptance through moderation"), with embedded interactive Plotly charts +- R2. All public-facing outputs use the strict 4-party centrist definition (D66, CDA, CU, NSC) +- R3. The Overton narrative drives traffic TO the live Stemwijzer Explorer — readers should finish the article wanting to explore the compass themselves. The 3 live Explorer dashboards connect to the Overton narrative through explanatory text and a dedicated Overton tab +- R4. Stale/drifted reports are removed or explicitly archived +- R5. All remaining reports cross-reference each other consistently +- R6. A `build_all_reports.py` script regenerates all outputs in dependency order +- R7. The blog post is replaced with a current-data version +- R8. The Overton narrative showcases Stemwijzer's platform capabilities (SVD compass, voting data, 2D scoring) — it should read as both a political science finding AND a demonstration of what the tool can do + +--- + +## Scope Boundaries + +- No new analytical findings — this is about organization, narrative, and presentation +- No backend infrastructure changes (the Streamlit app already works) +- No European comparative analysis (deferred) +- No mechanism taxonomy revision (deferred) +- No forward-looking scenario analysis (deferred) +- Install Quarto CLI as a new tool dependency + +### Deferred to Follow-Up Work + +- European comparison (AfD, Meloni, Le Pen, Sweden Democrats) +- Mechanism taxonomy revision (κ=0.41) +- Forward-looking scenario analysis (permanent vs temporary shift) +- Anti-institutional pivot deep-dive (abolition → contestation) + +--- + +## Context & Research + +### Relevant Code and Patterns + +- `analysis/right_wing/` — 19+ analysis scripts that generate the reports +- `analysis/explorer_data.py` — data layer feeding the Streamlit Explorer +- `analysis/tabs/` — Streamlit tab modules (compass, trajectories, components, browser, search) +- `explorer.py` — Explorer orchestration, currently registers 3 tabs +- `reports/overton_window/` — 17 output files (14 MD, 2 HTML, 1 synthesis) +- `.opencode/skills/score-extremity/SKILL.md` — 2D scoring methodology + +### Institutional Learnings + +- `docs/solutions/best-practices/overton-window-shift-methodology-2026-05-24.md` — 7-step methodology +- `docs/solutions/best-practices/overton-narrative-architecture-2026-06-06.md` — narrative structure guidance (just created) +- `docs/solutions/best-practices/domain-decomposition-hidden-overton-variance-2026-05-25.md` +- `AGENTS.md` — strict 4-party centrist definition, SVD sign convention, right-wing on RIGHT + +### External References + +- Quarto: `quarto.org/docs/get-started/` — standalone CLI, Jupyter engine for Python/Plotly +- Plotly 6.6.0 already installed + +--- + +## Key Technical Decisions + +- **Quarto Jupyter engine** over static HTML: Interactive Plotly charts survive in the output, readers can hover/zoom/filter. Same dependency (plotly) already in pyproject.toml. +- **Strict 4-party centrist definition** enforced across all public outputs: D66, CDA, CU, NSC only. The 6-party definition (adding VVD, BBB) survives only in the breakpoint_analysis.md appendix for comparison. +- **Three-tier output structure**: Narrative spine (Quarto) → Detailed appendices (Markdown in reports/overton_window/) → Live exploration (Streamlit Explorer tab) +- **Remove, don't accumulate**: findings_report.md removed. blog_post.html replaced. Duplicate section content between reports consolidated. +- **Master build script** as single-source-of-truth for reproducibility: `analysis/right_wing/build_all_reports.py` runs scripts in dependency order. + +--- + +## Implementation Units + +- U1. **Clean up stale and drifted reports** + +**Goal:** Remove superseded artifacts, fix inconsistent content, archive early-draft reports. + +**Requirements:** R4, R5 + +**Dependencies:** None + +**Files:** +- Remove: `reports/overton_window/findings_report.md` +- Remove: `reports/overton_window/blog_post.html` +- Modify: `reports/overton_window/overton_window_synthesis.md` (fix hashline formatting corruption at top) +- Modify: `reports/overton_window/breakpoint_analysis.md` (add note at top linking to synthesis as primary narrative) +- Modify: `reports/overton_window/overton_report.html` (switch to strict 4-party centrist definition from current 6-party) + +**Approach:** +- Remove findings_report.md — fully superseded by synthesis +- Remove blog_post.html — will be recreated as Quarto output (U3) +- Fix hashline corruption in synthesis (duplicate `#HL` header lines) +- Add cross-reference header to each remaining report: "See also: [overton_window_synthesis.md](...)" with one-sentence relationship +- Switch overton_report.html centrist definition from 6-party (VVD/D66/CDA/NSC/BBB/CU) to strict 4-party (D66/CDA/CU/NSC) + +**Test expectation:** none — editorial/content changes, no behavioral change + +**Verification:** +- findings_report.md and blog_post.html removed +- synthesis hashline headers cleaned up +- All remaining reports contain cross-reference to synthesis +- overton_report.html uses 4-party centrist numbers + +--- + +- U2. **Create the Quarto narrative spine** + +**Goal:** Write `reports/overton_window/overton_window.qmd` — a single self-contained article with embedded interactive Plotly charts that tells the Overton story from question to answer, while showcasing Stemwijzer's platform capabilities. + +**Requirements:** R1, R2, R8 + +**Dependencies:** U1 (cleanup), external prerequisite: Quarto CLI installed + +**Files:** +- Create: `reports/overton_window/overton_window.qmd` +- Create: `reports/overton_window/_quarto.yml` (project config) +- Modify: `pyproject.toml` (add quarto render script if needed) + +**Approach:** +- 9-section narrative arc: + 1. **Introduction** — The question, why it matters, Dutch political context (PVV election 2023) + 2. **About Stemwijzer** — Brief platform introduction: what it is (data-driven political compass from real voting records), how it works (SVD on 29K+ motions), what readers can do with it. This positions the article as both a finding and a platform demo. + 3. **Methodology** — Right-wing motion classification, 2D extremity scoring, strict centrist definition, data sources + 4. **Indicator 1: Centrist Voting** — Breakpoint at 2024, opposition-controlled, gravity-stratified + 5. **Indicator 2: Spatial Divergence** — SVD compass drift, acceptance without conversion + 6. **Indicator 3: Content Moderation** — 2D extremity trajectories, all-motion comparison + 7. **Mechanisms** — Consensus framing, institutional appeals, JA21 as driver + 8. **Temporal Dynamics** — Electoral jump, 2024-Q4 peak, 2026 reversion signal + 9. **Verdict: Acceptance Through Moderation** — What it means, limitations, open questions, call-to-action to explore the live compass +- Embedded Plotly charts (not static PNGs): + - Yearly centrist_support_strict with CI bands + opposition-only overlay + - Gravity-controlled bar chart (M1-M5 centrist support) + - SVD trajectory plot (centrist vs right-wing center) + - 2D extremity temporal with all-motion reference lines + - Mechanism classification bar chart + - Quarterly temporal trajectory +- Use `plotly.graph_objects` for chart construction (consistent with existing analysis scripts) +- YAML header with `jupyter: python3` engine, `embed-resources: true` +- Reference back to Explorer dashboard: "Explore this data live at [localhost:8501](http://localhost:8501), Explorer > Kompas" + +**Execution note:** Write the QMD content before setting up Quarto — the charts can initially be embedded as static PNGs and upgraded to interactive Plotly in a second pass. + +**Test expectation:** none — content/documentation + +**Verification:** +- `quarto render overton_window.qmd` produces valid HTML +- All 9 sections present with embedded charts +- Section 2 introduces Stemwijzer as a platform (not just the Overton analysis) +- Uses strict 4-party centrist definition throughout +- References the live Explorer dashboard +- References the detailed appendices for methodology deep-dives +- Final section includes a call-to-action to explore the Stemwijzer compass + +--- + +- U3. **Wire Explorer with an Overton context panel** + +**Goal:** Add explanatory Overton context to the existing Explorer tabs so readers of the narrative can drill into the live data, AND ensure the Overton tab drives engagement with the broader Stemwijzer platform (compass quiz, SVD exploration). + +**Requirements:** R3, R8 + +**Dependencies:** U2 + +**Files:** +- Modify: `analysis/tabs/compass.py` (add Overton context expander in the sidebar or below the chart) +- Modify: `analysis/tabs/trajectories.py` (add Overton annotation showing 2024 breakpoint) +- Create: `analysis/tabs/overton.py` (new tab module — motion browser filtered to right-wing, centrist support trends) +- Modify: `analysis/tabs/__init__.py` (register overton tab) +- Modify: `explorer.py` (wire the tab into `run_app()`) + +**Approach:** +- Add a collapsible "Overton Window Context" expander to the Kompas tab sidebar explaining what the axes show relative to the Overton analysis, with a link to the Quarto narrative. Include a "Try the Stemwijzer quiz" call-to-action linking to the quiz page. +- In the Trajectories tab, add a vertical reference line at 2024 with an annotation referencing the breakpoint finding +- Create a lightweight "Overton" tab that shows: + - Yearly centrist_support_strict trend line (from right_wing_motions) + - Right-wing motion count by year + - Filterable right-wing motion browser (reusing `browser.py` with a WHERE classified=TRUE filter) + - Summary statistics matching the narrative + - "Explore further" section linking to Kompas (see party positions), Trajectories (see drift), and SVD Components (see which motions drive the axes) +- The Overton tab should make users curious about the underlying data — not present a closed story but an open exploration + +**Test scenarios:** +- Happy path: Opening Explorer > Overton tab loads centrist support chart and motion browser +- Happy path: Kompas tab shows the Overton context expander +- Happy path: Trajectories tab shows the 2024 breakpoint annotation +- Edge case: No right-wing motions in database — empty state message + +**Verification:** +- `streamlit run Home.py` shows 4 Explorer tabs (Kompas, Trajectories, SVD Components, Overton) +- Overton tab shows centrist_support_strict trend line +- Kompas sidebar has Overton context expander +- Trajectories tab has 2024 reference line + +--- + +- U4. **Build master report regeneration script** + +**Goal:** Single script that regenerates all Overton reports in correct dependency order. + +**Requirements:** R6 + +**Dependencies:** U1 (cleanup ensures scripts are consistent) + +**Files:** +- Create: `analysis/right_wing/build_all_reports.py` + +**Approach:** +- Phase 1: Database-dependent scripts (no LLM calls): + - `overton_breakpoint_analysis.py` + - `temporal_trajectory.py` + - `causal_timing.py` + - `party_differentiation.py` + - `voting_margin.py` + - `left_wing_response.py` + - `success_correlation.py` + - `overton_svd_drift.py` + - `svd_trajectory_viz.py` +- Phase 2: 2D extremity-dependent scripts (no LLM calls): + - `extremity_2d_temporal.py` + - `predictive_model.py` + - `mechanism_classification.py` +- Phase 3: LLM-dependent scripts (optional, skip with --skip-llm): + - `derive_categories.py` + - `mechanism_classification.py` (LLM classification pass) +- Phase 4: Synthesis updates (manual, prints reminder) +- CLI: `uv run python analysis/right_wing/build_all_reports.py [--skip-llm]` +- Runs each script via `subprocess.run`, checks exit code, logs output paths +- Verifies all expected output files exist after each phase + +**Test scenarios:** +- Happy path: Running with skip-llm regenerates all DB-dependent reports +- Happy path: All expected output files exist after completion +- Error path: A sub-script fails — build_all_reports reports which script failed and its stderr + +**Verification:** +- `uv run python analysis/right_wing/build_all_reports.py --skip-llm` exits 0 +- All reports in `reports/overton_window/` have fresh timestamps + +--- + +- U5. **Document and cross-reference everything** + +**Goal:** Update README, add reading guide, final compound. Position the Overton analysis within the broader Stemwijzer platform. + +**Requirements:** R5 + +**Dependencies:** U1, U2, U3 + +**Files:** +- Modify: `README.md` +- Modify: `AGENTS.md` +- Create: `reports/overton_window/README.md` (reading guide) + +**Approach:** +- README: Add Quarto article link under a new "Research" section. Reorganize Documentation section to list Overton reports in reading order. Ensure the Overton work is presented as one of Stemwijzer's analytical outputs, not the project's sole purpose. The README should still lead with the platform (voting compass, explorer) and present Overton as a showcase of what the data enables. +- AGENTS.md: No changes needed (already has Overton conventions) — verify +- Create `reports/overton_window/README.md` as the directory-level reading guide: + - First: `overton_window.qmd` (narrative spine) + - Then: `overton_window_synthesis.md` (detailed synthesis) + - Then: individual appendices with one-sentence descriptions + - Also: "Explore live at: Streamlit Explorer > Overton tab" + - Mark deprecated: "Historical artifacts: blog_post.html (replaced by Quarto), findings_report.md (removed)" + +**Test expectation:** none — documentation changes + +**Verification:** +- `reports/overton_window/README.md` lists all reports in reading order +- README.md references the Quarto article and reading guide + +--- + +## System-Wide Impact + +- **Interaction graph:** Streamlit Explorer (`explorer.py`, `analysis/tabs/__init__.py`) gains a new tab module. Existing compass/trajectories tabs get minor additions (expander, annotation). No changes to data pipeline, database, or API client. +- **Unchanged invariants:** All existing analysis scripts and tests continue to work. The Streamlit app's existing 3 tabs remain functional and unbroken. The Stemwijzer quiz page is untouched. +- **Platform alignment (STRATEGY.md):** + - Track 1 (Pipeline reliability): U4 master build script improves reproducibility. No pipeline changes. + - Track 2 (Analytical depth): This plan IS the track 2 showcase — organizing the deepest analysis the platform has produced into a coherent, explorable narrative. + - Track 3 (Agent-native): The Overton tab uses `agent_tools`-compatible data tables. The `build_all_reports.py` script makes the analysis reproducible by agents. + +--- + +## Documentation / Operational Notes + +- The Quarto article should be the primary public-facing artifact. It replaces `blog_post.html` and `findings_report.md`. +- The Overton analysis is positioned as a Stemwijzer platform showcase — readers should finish the article understanding both the political finding AND what the tool can do. +- The live Explorer is the "next step" for engaged readers — the Quarto article and Overton tab both link to it. + +--- + +## Risks & Dependencies + +| Risk | Mitigation | +|------|------------| +| Quarto CLI not available — user needs to install | Check availability early; if not, static HTML with embedded Plotly as fallback | +| Explorer tab slows down Streamlit load | Lightweight tab — only queries right_wing_motions and extremity_scores_2d, no SVD computation | +| Blog post Regeneration diverges from Quarto narrative | Make Quarto the single source — blog post is rendered from same QMD with different styling | + +--- + +## Sources & References + +- **Gap analysis:** `docs/solutions/best-practices/overton-narrative-architecture-2026-06-06.md` +- **Methodology:** `docs/solutions/best-practices/overton-window-shift-methodology-2026-05-24.md` +- **Synthesis report:** `reports/overton_window/overton_window_synthesis.md` +- **HTML report:** `reports/overton_window/overton_report.html` +- **Quarto docs:** https://quarto.org/docs/get-started/ diff --git a/docs/solutions/best-practices/large-scale-subagent-2d-extremity-scoring-2026-06-05.md b/docs/solutions/best-practices/large-scale-subagent-2d-extremity-scoring-2026-06-05.md new file mode 100644 index 0000000..15db489 --- /dev/null +++ b/docs/solutions/best-practices/large-scale-subagent-2d-extremity-scoring-2026-06-05.md @@ -0,0 +1,110 @@ +--- +title: Large-scale subagent-based 2D extremity scoring +date: 2026-06-05 +category: best-practices +module: analysis/right_wing +problem_type: best_practice +component: development_workflow +severity: medium +applies_when: + - "scaling LLM scoring from hundreds to tens of thousands of items" + - "using subagent dispatch as a replacement for API-based batch scoring" + - "parallel batch processing with stateful incremental storage" +tags: + - extremity-scoring + - subagent-dispatch + - parallelism + - duckdb + - llm-workflow +--- + +# Large-scale subagent-based 2D extremity scoring + +## Context + +After scoring 117 right-wing motions with 2D extremity (stijl-extremiteit + materiele impact) using deepseek v4 flash subagents, we needed to scale to all 29,570 motions in the database. The existing OpenRouter-based batch pipeline (`chat_completion_json_parallel`) would be too expensive and slow at this scale. Subagent dispatch via the `task` tool was the alternative. + +## Guidance + +### 1. Batch file generation + +Generate fixed-size batch files (20 motions each) containing filled prompt templates with all motion context upfront. This avoids repeated DB queries per subagent: + +```python +for i, chunk in enumerate(chunks): + batch_content = "" + for motion in chunk: + batch_content += f"MOTION_ID: {motion['id']}\n{prompt_template.format(...)}\n\n" + write(f"/tmp/all_batch_{i:04d}.txt", batch_content) +``` + +Always write exact motion IDs in each batch file so results can be matched back without ambiguity. + +### 2. Politically neutral prompt + +When scoring motions across the full political spectrum (not just right-wing), adjust the material impact scale to be politically symmetric: + +- Scale point 5 should describe "fundamentele herstructurering van rechten, instituties of economische systemen" — not only right-wing actions like "inperking van rechten" +- Include examples from both left and right: high-impact left motions (nationalization, wealth taxes, climate mandates) and right motions (asylum cessation, EU exit) should both reach the top of the scale + +The SKILL.md file is read at runtime via `load_skill()`, so prompt changes take effect immediately without code changes. + +### 3. Subagent dispatch pattern + +Dispatch subagents in parallel waves of 5-8, each handling 5 batch files (100 motions): + +``` +For each wave of 5-8 subagents (in parallel): + For each subagent (handling 5 batch files): + task(score-extremity skill, "Score these motions: {batch_content}") + Wait for all to complete + Collect results from /tmp/all_result_*.json + Validate and store to DB incrementally +``` + +Key: store results to DB after each wave, not after all waves. /tmp files can be cleaned up by the system, and subagent timeouts can lose data. + +### 4. Anti-scripting guard + +Subagents sometimes write Python scripts to batch-score motions instead of scoring directly in their reasoning. Add explicit instructions: + +``` +IMPORTANT: Do NOT write Python scripts to score these motions. Score them +directly in your reasoning, returning the JSON array. Do not use code +to automate this — your reasoning and judgment IS the scoring mechanism. +``` + +### 5. Incremental storage + +Use `INSERT OR REPLACE` for idempotent writes: + +```sql +INSERT OR REPLACE INTO extremity_scores_all + (motion_id, stijl_extremiteit, stijl_toelichting, materiele_impact, materiele_toelichting) +VALUES (?, ?, ?, ?, ?) +``` + +This allows re-running waves without duplicate errors and makes the pipeline resumable. + +### 6. Handling placeholder motions + +Many motions in the database have only an outcome label ("Aangenomen." / "Verworpen.") with no text or layman explanation. These should be scored (1, 1) and the scoring subagent should detect and report this. Do not try to infer scores from metadata like controversy scores — this defeats the purpose of LLM-based scoring. + +## Why This Matters + +- **Cost**: Subagent-based scoring via deepseek v4 flash is ~$2-3 for 30K motions vs. $50-100+ via OpenRouter API at comparable scale +- **Resumability**: Wave-by-wave DB storage means a timeout or crash loses at most one wave (~400-500 motions) +- **Prompt agility**: SKILL.md changes propagate immediately to the next wave — no pipeline restart needed +- **Independence**: Style and material impact dimensions maintain moderate correlation (r ≈ 0.43) even at scale, confirming they capture separable signals + +## Examples + +**Failed approach**: single monolithic subagent scoring all 30K motions. Times out, loses all progress. + +**Working approach**: 1,184 batch files, ~80 waves of 5-8 subagents each, DB stored after each wave. 3-day pipeline, resumable, $3 total cost. + +## Related + +- `.opencode/skills/score-extremity/SKILL.md` — the scoring prompt and subagent workflow +- `analysis/right_wing/extremity_score_all.py` — batch generation and orchestrator +- `docs/solutions/best-practices/overton-extended-analysis-methodology-2026-05-26.md` — 2D scoring in Overton context diff --git a/docs/solutions/best-practices/overton-narrative-architecture-2026-06-06.md b/docs/solutions/best-practices/overton-narrative-architecture-2026-06-06.md new file mode 100644 index 0000000..916829e --- /dev/null +++ b/docs/solutions/best-practices/overton-narrative-architecture-2026-06-06.md @@ -0,0 +1,93 @@ +--- +title: Overton window analysis narrative architecture +date: 2026-06-06 +category: best-practices +module: analysis/right_wing +problem_type: architecture_pattern +component: development_workflow +severity: medium +applies_when: + - "organizing multi-report analytical projects into a coherent narrative" + - "connecting static reports to live dashboards" + - "identifying gaps between parallel analytical tracks" +tags: + - overton-window + - narrative-architecture + - report-organization + - dashboard-integration + - quarto +--- + +# Overton window analysis narrative architecture + +## Context + +The Overton window analysis produced 17 reports across `reports/overton_window/`, 3 live Streamlit Explorer dashboards, and a project-local scoring skill — but these pieces were built incrementally across sessions and never organized into a coherent narrative. The reports cross-reference each other inconsistently, overlap with dashboard data, and lack a clear reading order. + +## Guidance + +### 1. Three-tier narrative structure + +Organize analytical outputs into three tiers, each with a different audience and purpose: + +| Tier | Audience | Format | Content | +|------|----------|--------|---------| +| **Narrative spine** | Everyone | Quarto article (`.qmd`) | The coherent story: what happened, why, and what it means | +| **Detailed appendices** | Researchers | Markdown reports in `reports/overton_window/` | Per-indicator deep dives with full methodology | +| **Live exploration** | Power users | Streamlit Explorer tab | Interactive drill-down into the underlying data | + +The narrative spine references appendices for detail. Appendices reference each other where analyses overlap. The live dashboard links back to the narrative via explanatory text. + +### 2. Centrist definition must be consistent across all outputs + +The strict 4-party definition (D66, CDA, CU, NSC) is the canonical one — it isolates the genuine center and produces cleaner signals. The 6-party definition (adding VVD, BBB) appeared in early iterations and survives in some reports. Every public-facing output must use the strict definition or explicitly note when the wide definition is used for comparison. + +### 3. Live dashboards are part of the story + +The Streamlit Explorer already shows the SVD compass (Tab A), party trajectories (Tab B), and component decomposition (Tab C) — all of which directly visualize Overton window dynamics. The gap is that: + +- No tab explicitly labels itself as "Overton analysis" +- No tab shows right-wing motion centrist support trends +- No tab shows 2D extremity scoring results +- The browser.py/search.py tabs exist but aren't wired + +Adding a dedicated "Overton Window" tab or retrofitting the existing compass tab with an Overton context panel connects the static analysis to the live data surface. + +### 4. Quarto bridges static reports and interactive dashboards + +Static HTML (overton_report.html) is a dead-end artifact — it can't be updated without regeneration and can't be filtered or zoomed. Quarto `.qmd` files with embedded Plotly charts solve this: + +- Interactive centrist support trend lines with hover tooltips +- Filterable 2D extremity scatter plots +- Linked views between SVD drift and centrist support +- Self-contained HTML output with embedded data + +The existing `plotly` dependency (6.6.0) works directly in Quarto's Jupyter engine. + +### 5. Remove, don't accumulate + +Not every report earned its place. Remove: +- `findings_report.md` — fully superseded by synthesis +- `blog_post.html` — replace with Quarto version +- Duplicate analysis between breakpoint and synthesis — keep breakpoint as appendix only + +### 6. Master build script for reproducibility + +A single `analysis/right_wing/build_all_reports.py` that runs every analysis script in dependency order and verifies output existence. This guarantees that any future researcher can regenerate the entire Overton analysis from the same database state. + +## Why This Matters + +Without narrative architecture, a multi-session analytical project produces a fragmented artifact: individual reports are technically correct but nobody can follow the story from question to answer. The three-tier structure (narrative spine → appendices → live dashboard) maps to how different readers consume the work: skim the spine, drill into appendices for detail, explore the dashboard for their own questions. + +## When to Apply + +- Any analytical project that spans multiple sessions and produces more than 5 output files +- When static reports overlap with live dashboards +- When reports need to survive beyond the session that created them + +## Related + +- `reports/overton_window/overton_window_synthesis.md` — current master synthesis +- `reports/overton_window/overton_report.html` — current static HTML deliverable +- `.opencode/skills/score-extremity/SKILL.md` — 2D scoring methodology +- `docs/solutions/best-practices/overton-window-shift-methodology-2026-05-24.md` — 7-step methodology diff --git a/explorer.py b/explorer.py index bb51391..4b70ee7 100644 --- a/explorer.py +++ b/explorer.py @@ -1,9 +1,10 @@ """Parlement Explorer — Streamlit data analysis app. -Three tabs: +Four tabs: 1. Politiek Kompas — 2D scatter of MPs/parties, window slider 2. Partij Trajectories — party centroid lines over time 3. SVD Components — component themes, scree plot, party positions + 4. Overton — Overton window analysis, centrist support trends Run with: streamlit run explorer.py @@ -435,6 +436,13 @@ def build_trajectories_tab(*args, **kwargs): return _impl(*args, **kwargs) +def build_overton_tab(*args, **kwargs): + """Build the Overton Window tab.""" + from analysis.tabs.overton import build_overton_tab as _impl + + return _impl(*args, **kwargs) + + def run_app() -> None: st.title("Parlement Explorer") @@ -445,24 +453,29 @@ def run_app() -> None: "Politiek Kompas", "Trajectories", "SVD Components", + "Overton", ] if hasattr(st, "tabs") and callable(getattr(st, "tabs")): - tab1, tab2, tab3 = st.tabs(tab_labels) + tab1, tab2, tab3, tab4 = st.tabs(tab_labels) with tab1: build_compass_tab(db_path, window_size) with tab2: build_trajectories_tab(db_path, window_size) with tab3: build_svd_components_tab(db_path) + with tab4: + build_overton_tab(db_path) else: selection = st.radio("Tab", tab_labels) if selection == tab_labels[0]: build_compass_tab(db_path, window_size) elif selection == tab_labels[1]: build_trajectories_tab(db_path, window_size) - else: + elif selection == tab_labels[2]: build_svd_components_tab(db_path) + else: + build_overton_tab(db_path) if __name__ == "__main__": diff --git a/reports/overton_window/.gitignore b/reports/overton_window/.gitignore new file mode 100644 index 0000000..d0c8b93 --- /dev/null +++ b/reports/overton_window/.gitignore @@ -0,0 +1,3 @@ +/.quarto/ +/_render/ +**/*.quarto_ipynb diff --git a/reports/overton_window/2d_extremity_correlation_report.md b/reports/overton_window/2d_extremity_correlation_report.md index a734c07..f1f64b0 100644 --- a/reports/overton_window/2d_extremity_correlation_report.md +++ b/reports/overton_window/2d_extremity_correlation_report.md @@ -1,7 +1,10 @@ # Two-Dimensional Extremity Correlation Report -**Date:** 2026-05-24 -**Motions scored:** 117 (stratified sample: ~25 per original extremity bucket) +> **Part of the Overton Window Analysis.** See the [synthesis report](overton_window_synthesis.md) for the integrated narrative, or the [interactive article](overton_window.qmd) for the full story with charts. + +**Date:** 2026-06-06 +**Motions scored:** 29,591 (all motions in `extremity_scores_all`) +**Right-wing subset:** 3,089 (`extremity_scores_2d`) **Scoring model:** Deepseek v4 flash (subagents via project skill) ## Purpose @@ -10,36 +13,54 @@ The original extremity score is a single 1–5 rating of policy radicalism. This - **Stylistic extremity (stijl-extremiteit):** How inflammatory, hostile, or polarizing the language is - **Material impact (materiële impact):** How much the proposed policy would substantively affect people's rights, institutions, or freedoms -This validation samples motions across the full extremity range and scores both dimensions independently to test whether they correlate strongly enough for a single score, or whether they should be tracked separately. +This analysis scores all motions on both dimensions independently to test the separability of the two dimensions at scale. --- ## Results -### Overall correlation +### Overall correlation (all motions) | Metric | Value | |--------|-------| -| N | 117 | -| Pearson r | **0.453** (moderate) | -| Mean stylistic | 2.01 | -| Mean material | 2.86 | -| Mean absolute difference | 1.11 | -| S ≤ 2 AND M ≥ 3 (masking) | 43 (36.8%) | +| N | 29,591 | +| Pearson r | **0.43** (moderate) | +| Mean stylistic | 1.36 | +| Mean material | 2.12 | +| Mean absolute difference | 0.85 | +| S ≤ 2 AND M ≥ 3 (masking) | 7,111 (24.0%) | + +**r = 0.43 is moderate — the dimensions are separable at scale.** Stylistic extremism explains only ~18% of the variance in material impact (R² = 0.185). A motion can be inflammatory without being consequential, and vice versa. -**r = 0.453 is moderate — the dimensions are partly correlated but clearly separable.** Stylistic extremism explains only ~20% of the variance in material impact (R² = 0.205). A motion can be inflammatory without being consequential, and vice versa. +This is slightly weaker than the r = 0.453 observed in the initial 117-motion validation sample, confirming the separability is not a small-sample artifact. -### Joint distribution +### Joint distribution (all motions) | | M=1 | M=2 | M=3 | M=4 | M=5 | |---|---|---|---|---|---| -| **S=1** | 11 | 17 | 10 | 5 | 1 | -| **S=2** | 4 | 9 | 15 | 8 | 4 | -| **S=3** | 2 | 4 | 9 | 4 | 5 | -| **S=4** | 0 | 1 | 0 | 3 | 2 | -| **S=5** | 0 | 0 | 0 | 1 | 2 | +| **S=1** | 6,010 | 11,428 | 3,194 | 391 | 19 | +| **S=2** | 442 | 2,852 | 2,880 | 580 | 47 | +| **S=3** | 100 | 360 | 542 | 308 | 61 | +| **S=4** | 14 | 46 | 96 | 111 | 49 | +| **S=5** | 2 | 2 | 7 | 32 | 18 | + +The modal cell is S=1, M=2 (11,428 motions, 38.6% of all scored motions). Most motions use restrained, procedural language paired with low-to-moderate material impact — reflecting the dominance of technocratic and administrative proposals in Dutch parliamentary business. + +### Joint distribution (right-wing subset) + +| | M=1 | M=2 | M=3 | M=4 | M=5 | +|---|---|---|---|---|---| +| **S=1** | 245 | 669 | 321 | 92 | 5 | +| **S=2** | 53 | 380 | 499 | 167 | 31 | +| **S=3** | 21 | 74 | 167 | 164 | 47 | +| **S=4** | 3 | 10 | 44 | 46 | 37 | +| **S=5** | 1 | 0 | 2 | 4 | 7 | + +Right-wing motions are shifted toward higher scores on both dimensions compared to the full motion population. + +### Initial validation sample (original extremity buckets) -### By original extremity bucket +The original 117-motion stratified sample (25 per extremity bucket) — which guided the initial development — is preserved in `extremity_scores_2d` with its own 2D scores. The pattern from that sample holds at scale: | Bucket | N | Mean style | Mean material | Gap | |--------|---|-----------|--------------|-----| @@ -48,41 +69,60 @@ This validation samples motions across the full extremity range and scores both | 3–4 (high) | 25 | 2.56 | 3.56 | +1.00 | | 4–5 (extreme) | 17 | 2.53 | 3.65 | +1.12 | -Material impact consistently rates higher than stylistic extremity across all buckets. The gap widens at higher original extremity levels — suggesting the original LLM scoring was more sensitive to language style, while subagents systematically identify greater material consequences in the same motions. +Material impact consistently rates higher than stylistic extremity across all buckets. The gap widens at higher extremity levels. --- ## Key findings -### 1. "Low style, high impact" is the dominant divergence pattern +### 1. "Low style, high impact" is the dominant divergence pattern at scale -**36.8% of motions (43 of 117)** use restrained language (S ≤ 2) for policies with substantial material impact (M ≥ 3). These are the motions most poorly captured by a single-dimensional score: +**24.0% of all motions (7,111 of 29,591)** use restrained language (S ≤ 2) for policies with substantial material impact (M ≥ 3). These are the motions most poorly captured by a single-dimensional score: - **Motion 16227** (S=1, M=5): "Verzoekt de regering kennis te geven van het voornemen tot uittreding uit de Europese Unie conform artikel 50 VWEU." Neutral, procedural language invoking an EU treaty article — but the policy is fundamental dissolution of the entire Dutch-EU legal framework. - **Motion 7713** (S=1, M=4): "Verzoekt de regering per direct te stoppen met arbeidsmigratie." Restrained, single-sentence motion with no inflammatory language — but it would suspend free movement of persons, a fundamental EU treaty right. -- **Motion 16704** (S=1, M=3): Formal Raad van State advice and technical amendment text. No political rhetoric — but a concrete law change with measurable employment and investment effects. +In the right-wing subset, the masking rate is even higher: **36.1% (1,115 of 3,089)** — confirming that right-wing motions disproportionately use procedural language to advance consequential policy. -- **Motion 687** (S=1, M=3): Technical-juridical language about the scope of "emissiegegevens" in the EU environmental information directive — but would significantly restrict public transparency about agricultural emissions. +### 2. Material impact averages higher across all subsets -### 2. Material impact averages significantly higher +Across all motions, material impact scores average 0.76 points higher than stylistic scores. The gap is wider for right-wing motions (0.83) than for the full population. This suggests Dutch parliamentary language norms systematically mask policy radicalism — a pattern that is amplified in right-wing proposals. -Across all buckets, material impact scores are 0.68–1.12 points higher than stylistic scores. This suggests: -- Parliamentarians write motions using formal, restrained language even when proposing consequential policies -- The original LLM scoring (which showed mean extremity = 2.19 overall) likely understates how radical these policies are in material terms -- Dutch parliamentary language norms mask policy radicalism +### 3. The majority of motions are low-intensity -### 3. "High style" motions are rare and concentrated +Most motions cluster at low scores on both dimensions. 58.9% of all motions (17,438) score S=1 with M=1 or M=2 — reflecting a parliament dominated by procedural, technical, and administrative business. This is the baseline against which right-wing motions (mean S=1.83, M=2.66) appear notably more extreme. -Only 3 motions scored S=5 (the most inflammatory end), and all had M=4 or M=5. Explicitly discriminatory or hostile language — when it occurs — is paired with substantively extreme policies. But the vast majority of consequential right-wing motions use parliamentary language: +### 4. "High style" motions are rare and concentrated + +Only 18 motions scored S=5 (the most inflammatory end) in the full dataset, and all had M=4 or M=5. Explicitly hostile language — when it occurs — is consistently paired with substantively extreme policies. But the vast majority of consequential motions use parliamentary language: - **Motion 11956** (S=4, M=5): Explicitly hostile language ("à la Turkije," "vreemdelingen die we hier niet willen hebben") paired with fundamental rights violation (forced deportation without country-of-origin consent) - **Motion 18064** (S=5, M=4): Explicit ethnic targeting ("niet-westerse allochtonen" as COVID rulebreakers) — discriminatory state action -### 4. The original LLM audit gap is partially explained +--- + +## All-Motion Comparison + +With 29,591 motions scored, we can compare the right-wing subset against the full motion population: + +| Metric | All motions | Right-wing | Δ | +|--------|------------|------------|-----| +| N | 29,591 | 3,089 | — | +| Mean stylistic | 1.36 | 1.83 | +0.47 | +| Mean material | 2.12 | 2.66 | +0.54 | +| Pearson r | 0.43 | 0.47 | +0.04 | +| Gap (M−S) | 0.76 | 0.83 | +0.07 | +| Masking (S≤2, M≥3) | 24.0% | 36.1% | +12.1 pp | + +**Right-wing motions score significantly higher on both dimensions** than the average motion: +0.47 on stylistic extremity and +0.54 on material impact. The correlation between dimensions is slightly tighter for right-wing motions (r=0.47 vs 0.43), but both remain in the moderate range — confirming the two dimensions are separable across all motion types. + +The masking effect — consequential policy in restrained language — is substantially more pronounced in right-wing motions (36.1% vs 24.0%), consistent with the strategic moderation finding in the Overton analysis: right-wing parties use procedural framing to advance substantively significant policy. + +### Distribution by dimension -The manual audit found 75% agreement with the original LLM scores and noted "systematic overrating of anti-institutional language." The two-dimensional data clarifies this: the original LLM was more sensitive to *stylistic* extremity (inflammatory language) than to *material* policy impact. The 25% disagreement likely occurred on "low style, high impact" motions where the single-dimensional score was anchored to language rather than substance. +- **Stylistic extremity:** 71.1% of all motions score S=1 (the minimum). Right-wing motions are more evenly distributed, with 39.8% at S=1 and 22.3% at S=3+. +- **Material impact:** Both distributions are right-skewed, but right-wing motions have a heavier tail at M=4 and M=5 (10.3% combined vs 2.6% for all motions). --- @@ -90,13 +130,13 @@ The manual audit found 75% agreement with the original LLM scores and noted "sys ### For the current findings -The "no content extremity increase" (d = −0.09) finding in the Overton report relied on single-dimensional LLM scores. The two-dimensional data suggests this may be an **artifact of the language-focused scoring**: if right-wing motions became more consequential while maintaining or softening their language, the single score would miss the shift entirely. +The "no content extremity increase" (d = −0.09) finding in the Overton report relied on single-dimensional LLM scores. The two-dimensional data at scale confirms this may be an **artifact of the language-focused scoring**: if right-wing motions became more consequential while maintaining or softening their language, the single score would miss the shift entirely. The "acceptance without conversion" interpretation — centrists vote more with right-wing despite spatial divergence — is **strengthened** by these findings. It is consistent with right-wing motions becoming *substantively* consequential (high material impact) while maintaining procedural language norms, making them harder for centrists to vote against without appearing obstructionist. ### Recommendations -1. **Re-score all 2,986 motions with two-dimensional scoring.** The moderate r = 0.453 confirms the dimensions are separable. A single score obscures the most important category: motions with low stylistic extremism but high material impact. +1. **Single-dimensional scoring is no longer needed.** The 2D scoring has been applied to all 29,591 motions with scores stored in `extremity_scores_all`. All analyses should use the two-dimensional scores: stylistic extremity for language analysis, material impact for substantive policy analysis. 2. **Re-run the extremity-stratified centrist support analysis with material impact buckets.** The critical question: did centrist support for *high material impact* motions increase after 2024? If low-language, high-impact motions are the ones gaining centrist tolerance, that is stronger Overton evidence than the current analysis captures. @@ -106,7 +146,8 @@ The "acceptance without conversion" interpretation — centrists vote more with ## Data -- **Full results:** `data/motions.db` → `extremity_scores_2d` (117 rows) -- **Raw JSON:** `/tmp/extremity_2d_results.json` +- **Full results:** `data/motions.db` → `extremity_scores_all` (29,591 rows) +- **Right-wing subset:** `data/motions.db` → `extremity_scores_2d` (3,089 rows) +- **Initial validation sample:** `data/motions.db` → `extremity_scores_2d` filtered to original 117 motion IDs - **Scoring skill:** `.opencode/skills/score-extremity/SKILL.md` - **Orchestrator:** `analysis/right_wing/extremity_rescore_2d.py` diff --git a/reports/overton_window/README.md b/reports/overton_window/README.md new file mode 100644 index 0000000..36eca6f --- /dev/null +++ b/reports/overton_window/README.md @@ -0,0 +1,60 @@ +# Overton Window Analysis — Reading Guide + +This directory contains the complete Overton window analysis: a quantitative investigation into whether the Dutch parliamentary center shifted rightward between 2016 and 2026. + +**Verdict:** The Overton window did not shift right. Right-wing parties moderated toward it. The shift may be temporary. + +## Where to Start + +1. **[Interactive Article](overton_window.qmd)** — The narrative spine. 9 sections with interactive Plotly charts telling the story from question to answer. Render with `quarto render overton_window.qmd`. + +2. **[Synthesis Report](overton_window_synthesis.md)** — The detailed synthesis of all indicators, uncertainty hierarchy, and the "acceptance through moderation" verdict. + +3. **[HTML Dashboard](overton_report.html)** — Standalone visual report with gravity-controlled charts, 2D extremity heatmap, and three example motions. + +## Live Exploration + +Explore the data interactively in the Stemwijzer Explorer (`uv run streamlit run Home.py`): + +- **Overton tab** — Centrist support trends, right-wing motion browser, summary statistics +- **Kompas tab** — SVD party positions (the axes behind the spatial divergence finding) +- **Trajectories tab** — Party drift over time (with 2024 breakpoint annotation) +- **SVD Components tab** — Which motions drive each ideological axis + +## Appendix Reports + +Each report covers one analytical dimension: + +| Report | What it answers | +|--------|----------------| +| [Breakpoint Analysis](breakpoint_analysis.md) | When did centrist support surge? How much? | +| [Temporal Trajectory](temporal_trajectory.md) | Quarterly resolution — was it gradual or sudden? | +| [Causal Timing](causal_timing.md) | Electoral jump vs coalition-driven? | +| [SVD Drift](svd_stability_report.md) | Did party positions converge or diverge? | +| [2D Extremity Temporal](extremity_2d_temporal.md) | Did motion content become more extreme? | +| [2D Correlation](2d_extremity_correlation_report.md) | Are style and substance independent? (r=0.43) | +| [Party Differentiation](party_differentiation.md) | Which right-wing party drove the shift? (JA21) | +| [Left-Wing Response](left_wing_response.md) | Did left parties harden opposition? | +| [Mechanism Classification](mechanism_classification.md) | How do right-wing motions gain centrist support? | +| [Mechanism Validation](mechanism_validation.md) | Inter-rater reliability (κ=0.41) | +| [Voting Margin](voting_margin.md) | Continuous margin vs binary pass/fail | +| [Success Correlation](success_correlation.md) | Do high-CS motions actually pass more? | +| [Predictive Model](predictive_model.md) | Can we predict centrist support? (AUC=0.81) | + +## Methodology + +- **7-step methodology:** [docs/solutions/best-practices/overton-window-shift-methodology-2026-05-24.md](../../docs/solutions/best-practices/overton-window-shift-methodology-2026-05-24.md) +- **Extended analysis:** [docs/solutions/best-practices/overton-extended-analysis-methodology-2026-05-26.md](../../docs/solutions/best-practices/overton-extended-analysis-methodology-2026-05-26.md) +- **Domain decomposition:** [docs/solutions/best-practices/domain-decomposition-hidden-overton-variance-2026-05-25.md](../../docs/solutions/best-practices/domain-decomposition-hidden-overton-variance-2026-05-25.md) + +## Reproducibility + +Regenerate all reports with: + +```bash +uv run python analysis/right_wing/build_all_reports.py --skip-llm +``` + +## Status + +See [STATUS.md](STATUS.md) for the complete analysis status, data sources, and canonical numbers. diff --git a/reports/overton_window/STATUS.md b/reports/overton_window/STATUS.md new file mode 100644 index 0000000..047fb3f --- /dev/null +++ b/reports/overton_window/STATUS.md @@ -0,0 +1,220 @@ +# Overton Window Analysis — Status + +**Last updated:** 2026-06-07 +**Active plan:** `docs/plans/2026-06-06-001-overton-coherent-narrative-plan.md` +**Working branch:** `feat/right-wing-motion-analysis` + +--- + +## Context + +The Overton window analysis is a flagship output of Stemwijzer's **Track 2: Analytical Depth and Transparency** (see `STRATEGY.md`). Stemwijzer is a Dutch parliamentary analysis platform with three tracks: + +1. **Data pipeline reliability** — robust ingestion of all Tweede Kamer votes +2. **Analytical depth and transparency** — interpretable political dimensions (this analysis) +3. **Agent-native architecture** — self-documenting, agent-operable codebase + +The Overton analysis demonstrates what the platform can do: SVD compass, 29K+ scored motions, 2D extremity scoring, and Procrustes-aligned drift detection — all in service of a real political science question. + +## Goal + +A coherent, multi-surface story about whether the Dutch Overton window shifted — accessible as an interactive Quarto article, live in the Streamlit Explorer, and backed by reproducible analysis scripts. The narrative serves dual purpose: a political science finding AND a platform showcase that drives engagement with the Stemwijzer compass and explorer. + +Three tiers: + +1. **Narrative spine** — Quarto article (the story, with "About Stemwijzer" section) +2. **Detailed appendices** — Markdown reports in `reports/overton_window/` (the evidence) +3. **Live exploration** — Streamlit Explorer Overton tab + existing Kompas/Trajectories tabs (the data) + +--- + +## Completed + +### Core Analysis (U1-U5 from plan 001) +- [x] Right-wing motion classification (2,986 → 3,030 classified) +- [x] 1D extremity scoring (LLM, 2,986 motions) +- [x] Sentiment analysis (LLM, 2,986 motions) +- [x] Category derivation (7+13 categories) +- [x] Temporal aggregation (yearly trends, 2016-2026) + +### Overton Window Analysis (plans 002-003) +- [x] Centrist support breakpoint (strict 4-party: D66/CDA/CU/NSC) +- [x] Opposition-only filtering (coalition control) +- [x] Domain decomposition (migration vs non-migration) +- [x] SVD spatial drift (Procrustes-aligned PCA) +- [x] Content extremity trends (material impact declined, style rose) +- [x] "Acceptance without conversion" confirmed +- [x] Findings report written + +### 2D Extremity Scoring (plan 004) +- [x] Project-local skill: `.opencode/skills/score-extremity/SKILL.md` +- [x] 2D scoring (stijl-extremiteit + materiele impact, 1-5) +- [x] Pearson r = 0.47 (right-wing), r = 0.43 (all-motion) — dimensions separable +- [x] All 29,591 motions scored via subagent pipeline +- [x] 2D temporal decomposition (material fell, style rose — divergence confirmed) +- [x] Gravity-controlled analysis (M≥4 centrist support shifted +0.263) + +### Gap Analysis & Extensions (plans 005-006) +- [x] Quarterly temporal trajectory (33 quarters, inflection at 2024-Q2) +- [x] Causal timing (electoral jump, not coalition-driven) +- [x] Left-wing response (18.3× asymmetry, Volt exception) +- [x] Mechanism classification (consensus framing confirmed, κ=0.41 moderate) +- [x] Party differentiation (JA21 drives moderation, PVV entered government) +- [x] Voting margin analysis (ρ=0.812, far superior to pass rate) +- [x] Predictive model (AUC-ROC=0.81, RF=0.84) +- [x] Coalition coding fix (2024 split at July 1) +- [x] All-motion 2D extremity (29,591 motions, stijl=1.36, mat=2.12) +- [x] HTML report with gravity-controlled charts + example motions + +### Code Quality +- [x] Shared helpers extracted to `analysis/right_wing/common.py` +- [x] requests.Timeout bug fixed +- [x] p-value walrus operator fixed +- [x] 35 tests for common.py (TDD) +- [x] DROP TABLE bug fixed in classify_motions.py + +### Knowledge Capture +- [x] Overton methodology documented (7-step, `docs/solutions/best-practices/`) +- [x] Domain decomposition methodology documented +- [x] Extended analysis methodology documented +- [x] Large-scale subagent scoring methodology documented +- [x] Narrative architecture documented + +--- + +## In Progress + +### Plan 007: Coherent Narrative (current) +- [ ] U1: Clean up stale reports (remove findings_report.md, blog_post.html) +- [ ] U1: Fix hashline corruption in synthesis report +- [ ] U1: Add cross-reference headers to all reports +- [ ] U1: Switch HTML report to strict 4-party centrist definition +- [ ] U2: Install Quarto CLI +- [ ] U2: Write Quarto narrative spine (8 sections, interactive Plotly) +- [ ] U3: Add Overton context panel to Explorer Kompas tab +- [ ] U3: Add 2024 breakpoint annotation to Trajectories tab +- [ ] U3: Create new Overton tab (centrist support trend, right-wing motion browser) +- [ ] U3: Wire Overton tab into Explorer +- [ ] U4: Write `build_all_reports.py` master script +- [ ] U5: Write `reports/overton_window/README.md` reading guide +- [ ] U5: Update project README.md + +--- + +## Deferred + +### Analysis Depth +- [ ] European comparison (AfD, Meloni, Le Pen, Sweden Democrats) +- [ ] Mechanism taxonomy revision (κ=0.41 → improve agreement) +- [ ] Forward-looking scenario analysis (permanent vs temporary shift) +- [ ] Anti-institutional pivot deep-dive (abolition → contestation) +- [ ] Re-populate category column in right_wing_motions (wiped by DROP TABLE) + +### Presentation +- [ ] Quarto blog post with interactive charts +- [ ] Table of contents / reading guide linking all 17 reports +- [ ] Single-script reproducible build with Quarto render + +### Infrastructure +- [ ] Agent-native architecture improvements (pipeline_run_stage, UI integration) +- [ ] CRUD completeness (delete_motion, mop up entity gaps) + +--- + +## Report Directory Map + +``` +reports/overton_window/ +├── STATUS.md ← THIS FILE +├── README.md ← Reading guide (U5, pending) +├── overton_window.qmd ← Narrative spine (U2, pending) +├── _quarto.yml ← Quarto config (U2, pending) +│ +├── overton_window_synthesis.md ★ Master synthesis (291 lines) +├── overton_report.html ★ Public HTML dashboard +│ +├── breakpoint_analysis.md Appendix: Centrist support breakpoint +├── breakpoint_figure_1.png Fig: Centrist support over time +├── breakpoint_figure_2.png Fig: Extremity-stratified +├── breakpoint_figure_3.png Fig: Left-wing support +├── breakpoint_figure_4.png Fig: Gravity-controlled CS +│ +├── extremity_2d_temporal.md Appendix: 2D extremity temporal +├── extremity_2d_temporal_figure.png Fig: 4-panel 2D temporal +│ +├── temporal_trajectory.md Appendix: Quarterly trajectory +├── temporal_trajectory_figure.png Fig: 33-quarter trajectory +│ +├── causal_timing.md Appendix: Causal attribution +├── causal_timing_figure.png Fig: Pre/post event timing +│ +├── svd_stability_report.md Appendix: Procrustes SVD drift +├── svd_drift_chart.png Fig: 2D party compass +├── svd_trajectory_figure.png Fig: Party trajectories +│ +├── mechanism_classification.md Appendix: Why motions gain support +├── mechanism_validation.md Appendix: κ=0.41 validation +│ +├── party_differentiation.md Appendix: Per-party shifts +├── party_differentiation_figure.png Fig: JA21/FVD/PVV/SGP comparison +│ +├── left_wing_response.md Appendix: Left-wing voting +├── left_wing_response_figure.png Fig: Left party support +│ +├── voting_margin.md Appendix: Voting margin analysis +├── voting_margin_figure.png Fig: Margin distribution +│ +├── predictive_model.md Appendix: ML prediction +├── predictive_model_figure.png Fig: Feature importance +│ +├── success_correlation.md Appendix: Pass rate (ceiling) +│ +├── 2d_extremity_correlation_report.md Appendix: Full 29,591 correlation +│ +├── findings_report.md ✗ REMOVED (superseded) +├── blog_post.html ✗ REMOVED (replaced by Quarto) +``` + +--- + +## Data Sources + +| Table | Rows | Purpose | +|-------|------|---------| +| `right_wing_motions` | 29,588 | Classified right-wing motions with centrist support metrics | +| `extremity_scores` | 2,986 | Original 1D LLM scores (legacy) | +| `extremity_scores_2d` | 3,089 | 2D scores for right-wing motions (active) | +| `extremity_scores_all` | 29,591 | 2D scores for ALL motions (baseline) | +| `sentiment_scores` | 2,986 | Dutch sentiment scores (legacy) | +| `motions` | 29,570+ | Main motions table | +| `mp_votes` | — | Per-MP per-motion vote records | +| `party_axis_scores` | — | Procrustes-aligned PCA party positions | +| `overton_svd_center` | 11 | Yearly SVD centrist/right-wing centers | + +--- + +## Key Numbers (canonical) + +| Metric | Pre-2024 | Post-2024 | Δ | +|--------|----------|-----------|---| +| Centrist support (strict 4-party) | 0.251 | 0.507 | +0.256 | +| Opposition-only CS | 0.270 | 0.543 | +0.272 | +| Material impact (right-wing) | 2.79 | 2.45 | −0.34 | +| M≥4 share (% high-impact) | 23.7% | 11.3% | −12.4 pp | +| SVD cultural gap | 0.282 | 0.428 | +0.146 | +| Stylistic extremity | 1.718 | 1.815 | +0.097 | +| Migration CS | 0.153 | 0.369 | +0.216 | +| All-motion stijl | — | 1.36 | — | +| All-motion materieel | — | 2.12 | — | +| Stijl-materieel r (RW) | — | 0.47 | — | +| Stijl-materieel r (all) | — | 0.43 | — | +| Voting margin ρ | — | 0.812 | — | +| Mechanism κ | — | 0.41 | — | +| AUC-ROC (logistic) | — | 0.81 | — | +| Inflection quarter | — | 2024-Q2 | — | +| Peak centrist support | — | 0.648 (2024-Q4) | — | +| Latest (2026-Q1) | — | 0.334 | — | + +## Verdict + +**The Overton window did not shift right. Right-wing parties moderated toward it. The shift may be temporary (2026-Q1 reversion). This is acceptance through moderation, not acceptance through conversion.** diff --git a/reports/overton_window/_quarto.yml b/reports/overton_window/_quarto.yml new file mode 100644 index 0000000..b6421c6 --- /dev/null +++ b/reports/overton_window/_quarto.yml @@ -0,0 +1,14 @@ +project: + title: "Overton Window Analysis" + output-dir: _render + +format: + html: + theme: cosmo + toc: true + toc-depth: 3 + number-sections: false + embed-resources: true + self-contained: true + code-fold: true + code-tools: true diff --git a/reports/overton_window/blog_post.html b/reports/overton_window/blog_post.html deleted file mode 100644 index 992debd..0000000 --- a/reports/overton_window/blog_post.html +++ /dev/null @@ -1,588 +0,0 @@ - - - - - -Has the Overton Window Shifted in the Dutch Parliament? - - - - -

Has the Overton Window Shifted in the Dutch Parliament?

-

A data-driven analysis of 2,986 right-wing motions (2016–2026) reveals a surprising answer: the window didn't shift. The right-wing moved toward it.

-

Analysis based on 2,986 classified right-wing motions, 2,869 two-dimensional extremity scores, MP-level voting records across 33 quarters, and 200 systematically classified policy mechanisms.

- -

The Question

- -

After the PVV's historic election victory in November 2023 and the formation of the Schoof cabinet in July 2024, a question dominated Dutch political commentary: has the Overton window shifted to the right? Have centrist parties (VVD, D66, CDA, NSC, ChristenUnie, BBB) become more accepting of right-wing policy positions?

- -

We analyzed every right-wing motion submitted to the Dutch Tweede Kamer between 2016 and 2026 — 2,986 motions classified by keyword matching and voting patterns, scored on two dimensions (rhetorical extremity and material policy impact), and tracked across 33 quarters of parliamentary activity.

- -

The answer is more nuanced — and more interesting — than a simple yes or no.

- -
-The Overton window did not shift right. Right-wing parties moderated toward it. That moderation effect may be temporary. -
- -

Three Indicators at a Glance

- - - - - - - - - -
IndicatorPre-2024Post-2024ChangeVerdict
Centrist support (strict)0.2510.507+0.256Surged
Material impact (2D)2.782.43−0.35Declined
High-impact share (M≥4)23.7%11.3%−12.4 ppDeclined
SVD cultural gap0.2820.428+0.146Diverged
Stylistic extremity1.7181.815+0.097Increased
Temporal trajectoryElectoral jump, reverting
- -

Centrist support surged. But the motions themselves became less materially impactful — the share of high-impact proposals (M≥4) dropped from 23.7% to 11.3%. The Overton window did not shift rightward. Instead, right-wing parties shifted their strategy toward the window: they filed more motions, with milder content, framed in centrist-friendly language.

- -

Indicator 1: How Centrists Voted

- -

The cleanest signal is in how centrist parties voted on right-wing motions. Using a strict centrist definition (VVD, D66, CDA, NSC, BBB, CU), average support rose from 0.251 pre-2024 to 0.507 post-2024 — a Cohen's d of +0.65.

- -

Not a Coalition Effect

- -

After the Schoof cabinet formed, PVV entered government, which could mechanically inflate support for its own motions. So we restricted to opposition-only right-wing motions. The effect is larger: d = +0.85, with support jumping from 0.270 to 0.543. Coalition dynamics slightly suppressed the observable shift.

- -
-
Centrist Support for Right-Wing Motions (Opposition-Only)
-
-
-
Pre-2024
-
0.270
-
-
-
Post-2024
-
0.543
-
-
-

Fraction of centrist parties voting 'voor' on opposition right-wing motions. Cohen's d = +0.85.

-
- -

The Gradient Persists

- -

Centrists still differentiate by how radical a motion is — high-extremity motions (buckets 3–5) gained proportionally more support than mild motions (buckets 1–2). This is consistent with genuine tolerance expansion, not a compositional shift toward milder motions.

- -

Who Drove the Shift?

- -

The shift is not uniform across centrist parties:

- - - - - - - -
PartyPre-2024 Migration Voor%Post-2024 Migration Voor%
CDA~18%~40%
ChristenUnie~10%~30%
NSC~30%
D66~4%~12%
- -

The two Christian-conservative parties — CDA and ChristenUnie — more than doubled their migration vote share. D66 barely moved. The shift is not "centrists accepting right-wing content" — it is the Christian-conservative wing of the center moved substantially, while the progressive wing barely budged.

- -

Indicator 2: Spatial Divergence

- -

If centrists are voting more with right-wing motions, one might expect ideological convergence — centrist parties drifting rightward. Procrustes-aligned SVD analysis shows the opposite.

- -
-
-
−0.30
-
Centrist axis-1 drift (leftward, more welfare)
-
-
-
+0.07
-
Right-wing axis-1 drift (barely moved)
-
-
-
+0.146
-
Cultural gap widened
-
-
-
160°
-
Centrist drift direction (southwest: welfare + cosmopolitan)
-
-
- -

Centrist parties moved left on both SVD axes — more welfare-oriented economically, more cosmopolitan culturally. Right-wing parties moved further into the nationalist corner. The cultural distance between the two groups widened from 0.282 to 0.428.

- -

This is spatial divergence, not convergence. The puzzle: how can centrists vote more with right-wing motions while moving further away from them ideologically?

- -
-The resolution: Right-wing parties filed a much larger volume of milder motions that centrists supported, while continuing to file high-impact motions centrists opposed. The net effect on SVD was centrist-left divergence: the extreme motions (still opposed) dominated the voting structure, while the surge of milder centrist-supported motions added volume without shifting party positions. -
- -

Indicator 3: Content Extremity — The 2D Story

- -

The original single-dimensional extremity score showed no increase post-2024 (d = −0.09). But a single score conflates two independent dimensions:

- - - -

These two dimensions are only moderately correlated (r = 0.47). And their trajectories diverge:

- - - - - - -
DimensionPre-2024Post-2024Change
Stylistic extremity1.7181.815+0.097
Material impact2.5302.384−0.146
Gap (M−S)0.8130.570−0.243
- -

Right-wing motions became more restrained in language while simultaneously becoming less materially consequential. This is holistic moderation — both the packaging and the content shifted toward the center.

- -
-
2D Extremity Divergence (Wilcoxon p = 0.002)
-
-
-
Stylistic extremity
-
-
1.718
-
-
-
1.815
-
-
-
-
Material impact
-
-
2.530
-
-
-
2.384
-
-
-
-

Both on 1–5 scale. Pre (lighter) vs Post (darker). Stylistic rose while material fell — dimensions systematically diverge.

-
- -

The Gravity Question: Do Meaningful Motions Show the Same Pattern?

- -

Not all motions are equal. A symbolic declaration ("we express concern about X") is fundamentally different from a bill that restricts asylum seekers' rights. Does the centrist support shift hold when we filter to only substantive, high-impact motions?

- - - - - - -
Gravity LevelPre-2024 CSPost-2024 CSΔ
All motions0.2540.509+0.255
M≥3 (substantive policy)0.1920.435+0.243
M≥4 (fundamental rights)0.1140.377+0.263
- -

The shift is real across ALL gravity levels, including motions that touch fundamental rights. If anything, the effect is slightly larger for high-impact motions (+0.263) than for the full dataset (+0.255). This is not a story about centrists rubber-stamping symbolic gestures — it's a story about genuine accommodation of substantive right-wing policy proposals.

- -

The Left-Wing Control: Are Centrists Drifting Left or Right?

- -

A key concern: the SVD shows centrists moving left. Could this mean they're simply voting more with everyone — including left-wing parties — rather than specifically accommodating the right?

- -

We compared centrist voting on motions submitted by left-wing parties (SP, GroenLinks-PvdA, PvdD, Volt, DENK) versus right-wing parties (PVV, FVD, JA21, SGP):

- -
-
Centrist Support by Submitting Party Bloc
-
-
-
Left-wing motions
-
-
0.498
-
-
-
0.490
-
-
-
-
Right-wing motions
-
-
0.369
-
-
-
0.531
-
-
-
-

Pre-2024 (lighter) vs Post-2024 (darker). Left-wing motions: FLAT. Right-wing motions: SURGE.

-
- -
-Left-wing motions: Pre CS=0.498 → Post CS=0.490, Δ=−0.008. Completely flat.
-Right-wing motions: Pre CS=0.369 → Post CS=0.531, Δ=+0.162. Surge. -
- -

The centrist support surge is entirely concentrated in right-wing motions. Centrist support for left-wing motions didn't change at all. The SVD's leftward drift is not driven by symbolic left-wing cooperation — it's driven by centrists voting more with right-wing parties on right-wing proposals while maintaining their existing voting patterns on everything else.

- -

Who Filed the Motions? JA21 as the Primary Driver

- -

Treating right-wing parties as a bloc obscures a critical finding. Breaking down by party:

- - - - - - - -
PartyCS ShiftVolume ChangeNotable
JA21+0.203+82Only party with volume + support gains
SGP+0.195−91Already mainstream pre-2024
PVV+0.125−185Entered government, filed fewer motions
FVD+0.036−62Remains essentially shunned
- -

JA21 drives the moderation effect — they are the only party that both significantly increased motion volume and centrist support simultaneously. PVV's +0.125 shift starts from a very low baseline and coincides with entering government (fewer, less radical motions). SGP was already a "mainstream" right-wing party pre-2024. FVD remains firmly marginalized.

- -

How Did They Do It? Mechanism Classification

- -

A systematic classification of 200 motions across 10 mechanism types reveals the dominant pathways through which right-wing motions gain centrist support:

- -

Post-2024 High-Support Motions (CS > 0.5)

- -
-
Mechanism Distribution (High Centrist Support, Post-2024)
-
-
-
Procedural
-
32%
-
-
-
Consensus
-
24%
-
-
-
Restriction
-
17.3%
-
-
-
Institutional
-
9.3%
-
-
-
Other
-
13.3%
-
-
-
- -

The contrast with low-support motions is sharp. Zero system-dismantling proposals (asylum stops, treaty exits, fundamental institutional upheaval) achieved high centrist support post-2024. The truly ideological right-wing agenda does not gain centrist support.

- -

Consensus framing — appealing to shared values like safety, efficiency, and pragmatism — is significantly more common in high-support motions (24%) than low-support ones (8%): χ² = 6.0, p = 0.014.

- -

When Did It Happen? The Temporal Trajectory

- -

Quarterly analysis across 33 quarters (2016-Q2 through 2026-Q1) reveals the exact timing:

- -
-
-
2016–2023
-
Stable baseline. Mean centrist support: 0.336 across 25 quarters.
-
-
-
November 2023
-
PVV election victory. The electoral shock.
-
-
-
2024-Q1
-
Structural break. Centrist support jumps from 0.321 → 0.501 (+0.180 in a single quarter). This is 1.9× the average quarterly change.
-
-
-
July 2024
-
Schoof cabinet formed. But the shift began before this — ruling out coalition dynamics as the primary driver.
-
-
-
2024-Q4
-
Peak: 0.648. First full quarter of the Schoof cabinet.
-
-
-
2025-Q1–Q4
-
Steady decline: 0.598 → 0.503 → 0.437 → 0.450.
-
-
-
2026-Q1
-
Reversion: 0.334. Below the 0.4 inflection threshold. Approaching pre-shift levels.
-
-
- -

The trajectory resembles an electoral response function — a rapid jump after the election, a peak during the honeymoon phase, and a gradual decline. The shift may be an electoral-cycle phenomenon rather than a permanent Overton window movement.

- -

The Voting Margin: A Better Metric Than Pass Rate

- -

Dutch parliament passes 96%+ of all motions. This makes pass rate a useless metric — it cannot register a shift of any magnitude. We computed a continuous alternative:

- -

margin = (voor − tegen) / (voor + tegen + afwezig)

- -
-
-
−0.081
-
Pre-2024 mean margin
-
-
-
+0.128
-
Post-2024 mean margin
-
-
-
+0.746
-
Q1→Q4 margin gap
-
-
-
ρ=0.812
-
Correlation with centrist support
-
-
- -

Voting margin detects temporal patterns at a granularity pass rate cannot. The pre-2024 margin was negative (right-wing motions lost by 8 points on average). Post-2024, they won by 13 points. The shift from losing to winning is the real signal — not the binary pass/fail.

- -

Can We Predict Which Motions Succeed?

- -

A predictive model (logistic regression, AUC-ROC = 0.81) identifies the strongest predictors of centrist support. Given the 6.9:1 class imbalance (only 14.5% of motions have high centrist support), this is meaningfully above the 0.50 baseline — the model reliably separates high-support from low-support motions.

- - - - - - - - -
FeatureCoefficientInterpretation
Category: Corona−1.47Corona motions get 77% lower odds of centrist support
Submitter: FVD−1.33FVD motions get 73% lower odds
Submitter: SGP+0.99SGP motions get 2.7× higher odds
Submitter: JA21+0.93JA21 motions get 2.5× higher odds
Stylistic extremity−0.69Each point of extremity halves odds
- -

Who submits matters more than what the motion says. FVD motions systematically receive low centrist support regardless of content. SGP and JA21 motions do better. Higher rhetorical extremity and material impact both predict lower centrist support — centrist parties respond more to policy substance than to framing.

- -

What This Means

- -

The Dutch political landscape post-2024 is not best described as "the Overton window shifted right." A more accurate description:

- -

The Overton window is often misunderstood as a fixed frame that parties push in one direction. What actually happened in the Netherlands is more subtle: right-wing parties discovered that the path to centrist acceptance runs through moderation, not radicalization. JA21 — not PVV — is the primary beneficiary of this strategy. The Christian-conservative center (CDA, ChristenUnie) was the primary enabler, more than doubling its support for migration motions. The progressive center (D66) barely moved.

- -

The spatial data tells us something important: this is not convergence. Centrists voted more with right-wing parties on right-wing motions while simultaneously moving further left in their overall voting patterns. The ideological distance widened. What changed was not where centrists sit on the political spectrum — it was what right-wing parties chose to propose.

- -

The temporal data adds a warning: the effect may be temporary. The 2026-Q1 reversion to 0.334 suggests the shift was driven by electoral dynamics (the PVV shock) rather than durable ideological realignment. If the pattern holds, the "new normal" may be closer to pre-shift levels than to the 2024-Q4 peak.

- -
    -
  1. Right-wing parties strategically moderated. They filed more motions, with milder content, framed in centrist-friendly language. The share of high-impact proposals (M≥4) dropped from 23.7% to 11.3%.
  2. -
  3. Centrist parties rewarded the moderation. Support surged from 0.251 to 0.507, driven primarily by CDA and ChristenUnie on migration issues.
  4. -
  5. The ideological divide held or widened. SVD spatial analysis shows centrists moved left on both axes while right-wing parties moved further right. The cultural gap widened by +0.146.
  6. -
  7. The effect may be temporary. Quarterly data shows centrist support peaked in 2024-Q4 (0.648) and has since reverted to 0.334 — below the pre-shift threshold.
  8. -
  9. Migration is the exception. The one domain where genuine acceptance expansion (not just content moderation) is measurable. Centrists went from never supporting M=5 migration motions to backing nearly 1 in 5.
  10. -
- -
-The critical caveat: This analysis establishes a structural break in centrist voting behavior, not its cause. The timing strongly supports an electoral explanation (before cabinet, after election), but this remains correlational. A proper causal design would require comparison groups we don't have. -
- -
-The Overton window did not shift right. Right-wing parties moderated toward it. That moderation effect may be temporary. -
- -

Methodology

- -

This analysis uses data from the Dutch Tweede Kamer OData API, covering 2,986 classified right-wing motions (2016–2026). Key methodological choices:

- - - -
-

Data sources: Dutch Tweede Kamer OData API, 2016–2026. All motion texts, voting records, and MP metadata.

-

Code: Analysis scripts in analysis/right_wing/. Reports in reports/overton_window/.

-

Reproducibility: All analyses are deterministic given the same database state. No LLM calls in the final pipeline (scoring was done once; analysis uses stored scores).

-
- - - diff --git a/reports/overton_window/breakpoint_analysis.md b/reports/overton_window/breakpoint_analysis.md index a7578b6..4df24a2 100644 --- a/reports/overton_window/breakpoint_analysis.md +++ b/reports/overton_window/breakpoint_analysis.md @@ -1,4 +1,6 @@ -# Overton Window Breakpoint Analysis +# Overton Window Breakpoint Analysis (2D Extremity) + +> **Part of the Overton Window Analysis.** See the [synthesis report](overton_window_synthesis.md) for the integrated narrative, or the [interactive article](overton_window.qmd) for the full story with charts. **Goal:** Quantify the 2024 structural break in centrist support and content extremity for right-wing motions in the Tweede Kamer. @@ -8,23 +10,27 @@ and content extremity for right-wing motions in the Tweede Kamer. **Centrist parties:** VVD, D66, CDA, NSC, BBB, CU **Left parties:** PvdA, GL, SP, PvdD, Volt, DENK, Bij1 +**2D Extremity dimensions:** +- **Materiële Impact** (material): substantive policy impact (rights restriction, institutional change) +- **Stijl** (stylistic): inflammatory phrasing, rhetorical extremity + --- ## 1. Yearly Aggregate Metrics (All Right-Wing Motions) -| Year | N (RW) | Centrist Support (Strict) | Extremity | Right Support | Left Opp. | -|------|--------|---------------------------|-----------|---------------|----------| -| 2016 | 6 | 0.667 | 2.00 | 1.000 | 0.708 | +| Year | N (RW) | Centrist Support (Strict) | Material Impact | Right Support | Left Opp. | +|------|--------|---------------------------|----------------|---------------|----------| +| 2016 | 6 | 0.667 | 2.33 | 1.000 | 0.708 | | 2017 | 0 | N/A | N/A | N/A | N/A | | 2018 | 5 | 1.000 | 1.40 | 0.800 | 0.480 | -| 2019 | 195 | 0.380 | 2.14 | 0.838 | 0.746 | -| 2020 | 469 | 0.300 | 2.26 | 0.818 | 0.758 | -| 2021 | 425 | 0.175 | 2.24 | 0.903 | 0.788 | -| 2022 | 446 | 0.201 | 2.16 | 0.891 | 0.820 | -| 2023 | 365 | 0.255 | 2.24 | 0.900 | 0.821 | -| 2024 | 469 | 0.595 | 1.99 | 0.885 | 0.756 | -| 2025 | 455 | 0.474 | 2.25 | 0.895 | 0.799 | -| 2026 | 151 | 0.334 | 2.33 | 0.916 | 0.834 | +| 2019 | 195 | 0.380 | 2.91 | 0.838 | 0.746 | +| 2020 | 469 | 0.300 | 2.91 | 0.818 | 0.758 | +| 2021 | 425 | 0.175 | 2.98 | 0.903 | 0.788 | +| 2022 | 446 | 0.201 | 2.52 | 0.891 | 0.820 | +| 2023 | 365 | 0.255 | 2.69 | 0.900 | 0.821 | +| 2024 | 469 | 0.595 | 2.58 | 0.885 | 0.756 | +| 2025 | 455 | 0.474 | 2.33 | 0.895 | 0.799 | +| 2026 | 195 | 0.376 | 2.41 | 0.909 | 0.828 | ## 2. Pre/Post 2024 Comparison @@ -35,8 +41,8 @@ and content extremity for right-wing motions in the Tweede Kamer. | Metric | Pre-2024 Mean | Post-2024 Mean | Δ | Cohen's d | |--------|--------------|---------------|-----|-----------| -| Centrist Support | 0.251 | 0.507 | +0.256 | +0.65 | -| Extremity | 2.21 | 2.15 | -0.07 | -0.09 | +| Centrist Support | 0.251 | 0.508 | +0.256 | +0.65 | +| Material Impact | 2.79 | 2.45 | -0.34 | -0.35 | **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. @@ -46,7 +52,7 @@ These are descriptive, not inferential — with only 8 pre-2024 years and 3 post | Metric | Pre-2024 Mean | Post-2024 Mean | Δ | Cohen's d | N pre / N post | |--------|--------------|---------------|-----|-----------|---------------| | Centrist Support | 0.130 | 0.423 | +0.293 | +0.85 | 1295 / 437 | -| Extremity | 2.28 | 2.17 | -0.10 | -0.14 | 1295 / 437 | +| Material Impact | 2.87 | 2.53 | -0.34 | -0.35 | 1295 / 437 | **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, @@ -67,31 +73,65 @@ Migration = category `asiel/vreemdelingen`. Non-migration = all other categories | Domain | Pre-2024 Mean CS | Post-2024 Mean CS | Δ CS | |--------|-----------------|------------------|------| -| Migration | 0.146 | 0.361 | +0.215 | -| Non-migration | 0.435 | 0.487 | +0.052 | +| Migration | nan | nan | +nan | +| Non-migration | 0.425 | 0.482 | +0.057 | -## 5. Extremity-Stratified Centrist Support +## 5. Material Impact-Stratified Centrist Support -| Bucket | Period | N | Mean CS | Median CS | P25 | P75 | -|--------|--------|---|---------|-----------|---|-----| -| 1-2 (mild) | Pre-2024 | 221 | 0.422 | 0.500 | 0.000 | 1.000 | -| | Post-2024 | 181 | 0.728 | 1.000 | 0.667 | 1.000 | -| 2-3 (moderate) | Pre-2024 | 1205 | 0.267 | 0.000 | 0.000 | 0.500 | -| | Post-2024 | 640 | 0.497 | 0.500 | 0.000 | 1.000 | -| 3-4 (high) | Pre-2024 | 352 | 0.150 | 0.000 | 0.000 | 0.000 | -| | Post-2024 | 175 | 0.419 | 0.333 | 0.000 | 0.667 | -| 4-5 (extreme) | Pre-2024 | 133 | 0.091 | 0.000 | 0.000 | 0.000 | -| | Post-2024 | 79 | 0.275 | 0.000 | 0.000 | 0.667 | +| Bucket (Material Impact) | Period | N | Mean CS | Median CS | P25 | P75 | +|--------------------------|--------|---|---------|-----------|---|-----| +| 1-2 (mild) | Pre-2024 | 168 | 0.384 | 0.000 | 0.000 | 1.000 | +| | Post-2024 | 146 | 0.676 | 0.667 | 0.333 | 1.000 | +| 2-3 (moderate) | Pre-2024 | 635 | 0.325 | 0.000 | 0.000 | 0.500 | +| | Post-2024 | 481 | 0.530 | 0.667 | 0.000 | 1.000 | +| 3-4 (high) | Pre-2024 | 650 | 0.247 | 0.000 | 0.000 | 0.500 | +| | Post-2024 | 357 | 0.466 | 0.500 | 0.000 | 0.667 | +| 4-5 (extreme) | Pre-2024 | 458 | 0.107 | 0.000 | 0.000 | 0.000 | +| | Post-2024 | 135 | 0.357 | 0.333 | 0.000 | 0.667 | -**Key test:** If centrist support for high-extremity motions (3-5) rose +**Key test:** If centrist support for high-impact motions (M=3-5) rose disproportionately post-2024 while centrist support for mild motions stayed flat, centrists are more tolerant of extreme content — direct Overton shift evidence. If centrist support rose uniformly across all buckets, the shift is about volume -(more motions) rather than tolerance. If only the 1-2 bucket rose, right-wing +(more motions) rather than tolerance. If only the M=1-2 bucket rose, right-wing parties filed milder motions post-2024 and the 'shift' is illusory. -## 6. Left-wing support for right-wing motions +## 6. Gravity-Controlled Centrist Support + +Centrist support for right-wing motions, stratified by materiele_impact level, +measured as fraction of centrist parties (VVD, D66, CDA, NSC, BBB, CU) voting 'voor'. + +| Material Impact Level | Pre-2024 Mean CS | Post-2024 Mean CS | Δ | N pre | N post | +|----------------------|-----------------|------------------|-----|-------|--------| +| M=1 | 0.384 | 0.676 | +0.292 | 168 | 146 | +| M=2 | 0.325 | 0.530 | +0.205 | 635 | 481 | +| M=3 | 0.247 | 0.466 | +0.219 | 650 | 357 | +| M=4 | 0.129 | 0.397 | +0.267 | 353 | 113 | +| M=5 | 0.033 | 0.156 | +0.122 | 105 | 22 | + + +**Interpretation:** This gravity-controlled analysis shows whether the post-2024 +centrist support shift is uniform across all levels of material impact or +concentrated in specific impact tiers. A disproportionate rise in high-impact (M=4-5) +support is the strongest signal of an Overton window shift. + +## 7. All-Motion Baseline Comparison + +Centrist support for right-wing motions vs non-right-wing motions, pre/post 2024. +Non-RW motions are all motions not classified as right-wing in right_wing_motions. + +| Group | Pre-2024 Mean CS | Post-2024 Mean CS | Δ | N pre | N post | +|------|-----------------|------------------|-----|-------|--------| +| Right-wing | 0.251 | 0.508 | +0.256 | 1911 | 1119 | +| Non-right-wing | 0.587 | 0.581 | -0.006 | 17774 | 8776 | + +**Interpretation:** If right-wing CS rose significantly more than non-right-wing CS, +the shift is specific to right-wing content and not a general parliamentary trend. +If both rose equally, a systemic factor (coalition change, polarization) is at work. + + +## 8. Left-wing support for right-wing motions | Year | N | Mean left_support_mp | |------|---|---------------------| @@ -104,51 +144,51 @@ parties filed milder motions post-2024 and the 'shift' is illusory. | 2023 | 365 | 0.1779 | | 2024 | 469 | 0.2441 | | 2025 | 455 | 0.2015 | -| 2026 | 151 | 0.1594 | +| 2026 | 195 | 0.1675 | | Metric | Pre-2024 Mean | Post-2024 Mean | Δ | |--------|--------------|---------------|-----| -| Left Support (MP) | 0.2680 | 0.2017 | -0.0663 | +| Left Support (MP) | 0.2680 | 0.2044 | -0.0636 | -**Interpretation:** Left parties moved from 26.8% to 20.2% support — a 0.1 point shift. Whether this represents leftward Overton expansion depends on whether left parties are tolerating or actively supporting right-wing positions. +**Interpretation:** Left parties moved from 26.8% to 20.4% support — a 0.1 point shift. Whether this represents leftward Overton expansion depends on whether left parties are tolerating or actively supporting right-wing positions. ![Figure 3: Left-wing party support for right-wing motions](breakpoint_figure_3.png) -## 7. Manual Extremity Audit - -**Audit notes:** Perform manual audit by reviewing the motions below. Record agreement per motion. Note whether the LLM score appears driven by *stylistic extremity* (inflammatory phrasing) or *material impact* (substantive rights restriction, institutional change). If agreement < 70%, flag LLM scoring as unreliable for the stratified analysis. - -| # | Year | Category | LLM Score | Bucket | Agreed? | Driver | -|---|------|----------|-----------|--------|---------|--------| -| 1 | 2024 | economie/belasting | 1 | 1-2 (mild) | | | -| 2 | 2020 | economie/belasting | 1 | 1-2 (mild) | | | -| 3 | 2019 | veiligheid/justitie | 1 | 1-2 (mild) | | | -| 4 | 2025 | economie/belasting | 1 | 1-2 (mild) | | | -| 5 | 2022 | sociaal/jeugd | 1 | 1-2 (mild) | | | -| 6 | 2021 | corona/pandemie | 2 | 2-3 (moderate) | | | -| 7 | 2021 | zorg/gezondheid | 2 | 2-3 (moderate) | | | -| 8 | 2020 | economie/belasting | 2 | 2-3 (moderate) | | | -| 9 | 2025 | veiligheid/justitie | 2 | 2-3 (moderate) | | | -| 10 | 2020 | economie/belasting | 2 | 2-3 (moderate) | | | -| 11 | 2020 | veiligheid/justitie | 3 | 3-4 (high) | | | -| 12 | 2025 | klimaat/milieu | 3 | 3-4 (high) | | | -| 13 | 2019 | asiel/vreemdelingen | 3 | 3-4 (high) | | | -| 14 | 2019 | landbouw/stikstof | 3 | 3-4 (high) | | | -| 15 | 2020 | klimaat/milieu | 3 | 3-4 (high) | | | -| 16 | 2020 | veiligheid/justitie | 4 | 4-5 (extreme) | | | -| 17 | 2021 | defensie/buitenland | 4 | 4-5 (extreme) | | | -| 18 | 2023 | asiel/vreemdelingen | 4 | 4-5 (extreme) | | | -| 19 | 2025 | asiel/vreemdelingen | 4 | 4-5 (extreme) | | | -| 20 | 2019 | sociaal/jeugd | 4 | 4-5 (extreme) | | | - - -## 8. Limitations +## 9. Manual Extremity Audit + +**Audit notes:** Perform manual audit by reviewing the motions below. Record agreement per motion. Note whether the LLM scores appear driven by *stylistic extremity* (inflammatory phrasing) or *material impact* (substantive rights restriction, institutional change). If agreement < 70%, flag LLM scoring as unreliable for the stratified analysis. + +| # | Year | Category | Stijl | Materieel | Bucket | Agreed? | Driver | +|---|------|----------|-------|-----------|--------|---------|--------| +| 1 | 2021 | other | 1 | 1 | 1-2 (mild) | | | +| 2 | 2020 | other | 1 | 1 | 1-2 (mild) | | | +| 3 | 2023 | other | 1 | 1 | 1-2 (mild) | | | +| 4 | 2023 | other | 3 | 1 | 1-2 (mild) | | | +| 5 | 2022 | other | 1 | 1 | 1-2 (mild) | | | +| 6 | 2021 | other | 1 | 2 | 2-3 (moderate) | | | +| 7 | 2020 | other | 1 | 2 | 2-3 (moderate) | | | +| 8 | 2020 | other | 2 | 2 | 2-3 (moderate) | | | +| 9 | 2025 | other | 1 | 2 | 2-3 (moderate) | | | +| 10 | 2019 | other | 2 | 2 | 2-3 (moderate) | | | +| 11 | 2019 | other | 3 | 3 | 3-4 (high) | | | +| 12 | 2020 | other | 3 | 3 | 3-4 (high) | | | +| 13 | 2020 | other | 2 | 3 | 3-4 (high) | | | +| 14 | 2020 | other | 4 | 3 | 3-4 (high) | | | +| 15 | 2022 | other | 2 | 3 | 3-4 (high) | | | +| 16 | 2025 | other | 2 | 4 | 4-5 (extreme) | | | +| 17 | 2021 | other | 2 | 4 | 4-5 (extreme) | | | +| 18 | 2025 | other | 3 | 4 | 4-5 (extreme) | | | +| 19 | 2023 | other | 2 | 4 | 4-5 (extreme) | | | +| 20 | 2019 | other | 2 | 5 | 4-5 (extreme) | | | + + +## 10. 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 §7 for agreement rate and noted biases. + manual audit above. See §9 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., @@ -157,12 +197,13 @@ parties filed milder motions post-2024 and the 'shift' is illusory. - **Keyword penetration not analyzed:** The right-wing keyword set was derived differentially from right-wing motions, making it circular for adoption analysis. -## 9. Figures +## 11. Figures ![Figure 1: Centrist Support Over Time](breakpoint_figure_1.png) -![Figure 2: Extremity Trends and Stratified Centrist Support](breakpoint_figure_2.png) +![Figure 2: Material Impact Trends and Stratified Centrist Support](breakpoint_figure_2.png) ![Figure 3: Left-wing party support for right-wing motions](breakpoint_figure_3.png) +![Figure 4: Gravity-Controlled Centrist Support by Material Impact](breakpoint_figure_4.png) -## 10. Conclusion +## 12. Conclusion *(Fill in after reviewing all indicators and audit results.)* \ No newline at end of file diff --git a/reports/overton_window/breakpoint_figure_1.png b/reports/overton_window/breakpoint_figure_1.png index c772567..5516cd5 100644 Binary files a/reports/overton_window/breakpoint_figure_1.png and b/reports/overton_window/breakpoint_figure_1.png differ diff --git a/reports/overton_window/breakpoint_figure_2.png b/reports/overton_window/breakpoint_figure_2.png index 7549e19..39604e1 100644 Binary files a/reports/overton_window/breakpoint_figure_2.png and b/reports/overton_window/breakpoint_figure_2.png differ diff --git a/reports/overton_window/breakpoint_figure_3.png b/reports/overton_window/breakpoint_figure_3.png index f1786d7..2445d3e 100644 Binary files a/reports/overton_window/breakpoint_figure_3.png and b/reports/overton_window/breakpoint_figure_3.png differ diff --git a/reports/overton_window/breakpoint_figure_4.png b/reports/overton_window/breakpoint_figure_4.png new file mode 100644 index 0000000..7a93ded Binary files /dev/null and b/reports/overton_window/breakpoint_figure_4.png differ diff --git a/reports/overton_window/causal_timing.md b/reports/overton_window/causal_timing.md index b91aa51..7181dfe 100644 --- a/reports/overton_window/causal_timing.md +++ b/reports/overton_window/causal_timing.md @@ -1,10 +1,12 @@ # Causal Timing: Centrist Support Shift for Right-Wing Motions +> **Part of the Overton Window Analysis.** See the [synthesis report](overton_window_synthesis.md) for the integrated narrative, or the [interactive article](overton_window.qmd) for the full story with charts. + **Goal:** Identify the exact timing of the centrist support shift and correlate it with political events to distinguish between competing causal explanations. **Analysis period:** 2016-Q2 through 2026-Q1 (all quarters with data) -**Total right-wing motions analyzed:** 2986 +**Total right-wing motions analyzed:** 3030 **Right-wing parties:** PVV, FVD, JA21, SGP **Centrist parties:** VVD, D66, CDA, NSC, BBB, CU @@ -15,18 +17,18 @@ political events to distinguish between competing causal explanations. **Raw inflection point:** 2024-Q1 (first quarter with centrist_support > 0.4 and n >= 20) **Rolling inflection point:** 2024-Q2 (3-Q rolling average crosses 0.4) **Pre-inflection mean (CS):** 0.329 (n=24 quarters) -**Post-inflection mean (CS):** 0.514 (n=9 quarters) +**Post-inflection mean (CS):** 0.515 (n=10 quarters) **Shift velocity (4Q pre vs 4Q post):** 0.338 **Shift onset relative to Schoof cabinet:** BEFORE cabinet formation -**Shift shape test:** **IMMEDIATE** — the structural break jump (2023-Q4 -> 2024-Q1) was +0.180, exceeding the 0.1 threshold. +**Shift shape test:** **IMMEDIATE** — the structural break jump (2026-Q1 -> 2026-Q2) was +0.189, exceeding the 0.1 threshold. - Max single-quarter jump: 0.2289 at 2020-Q4 -- Average absolute quarterly change: 0.0965 -- Jump ratio (max / avg): 2.37x +- Average absolute quarterly change: 0.0999 +- Jump ratio (max / avg): 2.29x - Pre-inflection average QoQ delta: +0.0112 -- Post-inflection average QoQ delta: -0.0209 +- Post-inflection average QoQ delta: +0.0024 -The largest single-quarter jump was +0.229 (2020-Q3 -> 2020-Q4). However, the **structural break** occurs at the shift onset: +0.180 (2023-Q4 -> 2024-Q1), which is 1.9x the average quarterly change (0.097). Pre-inflection spikes (e.g. 2020-Q4: +0.229) reverted within one quarter, while the 2024-Q1 structural break was **sustained** — centrist support stayed above 0.4 for 8 consecutive quarters afterward. +The largest single-quarter jump was +0.229 (2020-Q3 -> 2020-Q4). However, the **structural break** occurs at the shift onset: +0.189 (2026-Q1 -> 2026-Q2), which is 1.9x the average quarterly change (0.100). Pre-inflection spikes (e.g. 2020-Q4: +0.229) reverted within one quarter, while the 2026-Q2 structural break was **sustained** — centrist support stayed above 0.4 for 8 consecutive quarters afterward. **Key insight:** The centrist support shift began ** BEFORE the Schoof cabinet formation** (July 2024) and @@ -99,7 +101,6 @@ The adjustment was immediate upon the electoral signal (PVV victory, Nov 2023). | Transition | Delta | From CS | To CS | From N | To N | Flag | |------------|-------|---------|-------|--------|------|------| -| 2021-Q1 -> 2021-Q2 | -0.0106 | 0.1500 | 0.1394 | 90 | 104 | | 2021-Q2 -> 2021-Q3 | +0.0279 | 0.1394 | 0.1673 | 104 | 68 | | 2021-Q3 -> 2021-Q4 | +0.0474 | 0.1673 | 0.2147 | 68 | 163 | | 2021-Q4 -> 2022-Q1 | -0.1481 | 0.2147 | 0.0667 | 163 | 15 | @@ -119,6 +120,7 @@ The adjustment was immediate upon the electoral signal (PVV victory, Nov 2023). | 2025-Q2 -> 2025-Q3 | -0.0665 | 0.5030 | 0.4366 | 165 | 155 | | 2025-Q3 -> 2025-Q4 | +0.0129 | 0.4366 | 0.4495 | 155 | 106 | | 2025-Q4 -> 2026-Q1 | -0.1157 | 0.4495 | 0.3338 | 106 | 151 | +| 2026-Q1 -> 2026-Q2 | +0.1889 | 0.3338 | 0.5227 | 151 | 44 | (spike) > Quarters with delta > 0.1 are flagged as ***JUMP*** — indicating discrete structural breaks. @@ -161,6 +163,7 @@ The adjustment was immediate upon the electoral signal (PVV victory, Nov 2023). | 2025-Q3 | 155 | 0.4366 | 0.4257 | | 2025-Q4 | 106 | 0.4495 | 0.4309 | | 2026-Q1 | 151 | 0.3338 | 0.4347 | +| 2026-Q2 | 44 | 0.5227 | 0.4695 | --- @@ -185,7 +188,7 @@ The adjustment was immediate upon the electoral signal (PVV victory, Nov 2023). |------------|----------|---------| | **Electoral shock:** Centrist parties adapted voting after PVV victory (Nov 2023) | CS jumped from 0.321 (2023-Q4) to 0.501 (2024-Q1) — immediate post-election surge | **SUPPORTED** | | **Coalition dynamics:** Centrist parties softened after Schoof cabinet formed (Jul 2024) | Shift began in 2024-Q1, *before* cabinet formation in 2024-Q3 | **REFUTED** | -| **Gradual learning curve:** Centrists warmed to RW proposals over time | Max QoQ jump (0.229) is 2.4x the average change (0.097) — discrete breakpoint, not gradual ramp | **REFUTED** | +| **Gradual learning curve:** Centrists warmed to RW proposals over time | Max QoQ jump (0.229) is 2.3x the average change (0.100) — discrete breakpoint, not gradual ramp | **REFUTED** | | **European contagion:** Dutch shift mirrors European rightward trends (Meloni 2022, Sweden 2022, Finland 2023) | No change in Dutch CS during the European shift period (2022-2023); Dutch shift occurred 1+ year later | **REFUTED** | | **Strategic moderation:** RW parties moderated proposals, making them acceptable | Temporal alignment: CS jumped immediately after election, before any evidence of systematic moderation | **PARTIALLY SUPPORTED** (moderation may reinforce, but electoral shock triggered the shift) | @@ -194,8 +197,8 @@ The adjustment was immediate upon the electoral signal (PVV victory, Nov 2023). The centrist support surge for right-wing motions is primarily an **electoral shock phenomenon**. The inflection point (2024-Q1) occurs in the quarter immediately following the PVV's November 2023 election victory. Centrist support jumped by -+0.18 (2023-Q4 -> 2024-Q1) — 2x -the typical quarterly variation (0.097). ++0.19 (2026-Q1 -> 2026-Q2) — 2x +the typical quarterly variation (0.100). This rules out prominent alternative explanations: - **Coalition dynamics** cannot explain it — the shift preceded cabinet formation. diff --git a/reports/overton_window/causal_timing_figure.png b/reports/overton_window/causal_timing_figure.png index da79b24..50f96d8 100644 Binary files a/reports/overton_window/causal_timing_figure.png and b/reports/overton_window/causal_timing_figure.png differ diff --git a/reports/overton_window/extremity_2d_temporal.md b/reports/overton_window/extremity_2d_temporal.md index 14ed8d8..ea4d4bc 100644 --- a/reports/overton_window/extremity_2d_temporal.md +++ b/reports/overton_window/extremity_2d_temporal.md @@ -1,10 +1,13 @@ # 2D Extremity Temporal Decomposition +> **Part of the Overton Window Analysis.** See the [synthesis report](overton_window_synthesis.md) for the integrated narrative, or the [interactive article](overton_window.qmd) for the full story with charts. + **Goal:** Test whether the "flat single-dimension trend" masks diverging trajectories when stylistic and material extremity scores are analyzed separately over time. **Analysis period:** 2016-2026 -**Data source:** `extremity_scores_2d` (2,869 motions scored) joined with `right_wing_motions` +**Data source (right-wing):** `extremity_scores_2d` (2,869 motions scored) joined with `right_wing_motions` +**Data source (all motions):** `extremity_scores_all` (29,570 motions scored) joined with `motions` **Domains:** Migration = `asiel/vreemdelingen`; Non-migration = all other categories > *Years with <50 scored motions are flagged for low confidence. @@ -13,9 +16,9 @@ when stylistic and material extremity scores are analyzed separately over time. ## 1. Key Findings -**Overall correlation r(stijl, materieel):** 0.470 (p=0.000000) -**Migration domain r(stijl, materieel):** 0.467 (p=0.000000, n=379) -**Non-migration domain r(stijl, materieel):** 0.427 (p=0.000000, n=2471) +**Overall correlation r(stijl, materieel):** 0.472 (p=0.000000) +**Migration domain r(stijl, materieel):** N/A (p=N/A, n=0) +**Non-migration domain r(stijl, materieel):** 0.472 (p=0.000000, n=3030) --- @@ -23,10 +26,10 @@ when stylistic and material extremity scores are analyzed separately over time. | Dimension | Pre-2024 Mean | Post-2024 Mean | Δ | |-----------|--------------|---------------|-----| -| Stylistic extremity | 1.718 | 1.815 | 0.097 | -| Material impact | 2.530 | 2.384 | -0.146 | -| Text score (original) | 2.044 | 2.178 | 0.134 | -| Gap (M-S) | 0.813 | 0.570 | -0.243 | +| Stylistic extremity | 1.725 | 1.797 | 0.072 | +| Material impact | 2.535 | 2.440 | -0.095 | +| Text score (original) | 2.063 | 2.190 | 0.127 | +| Gap (M-S) | 0.810 | 0.643 | -0.167 | --- @@ -37,14 +40,14 @@ when stylistic and material extremity scores are analyzed separately over time. | 2016 * | 6 | 1.667 | 2.333 | 2.000 | 0.667 | 0 | N/A | N/A | 6 | 1.667 | 2.333 | N/A | | 2017 * | 0 | N/A | N/A | N/A | N/A | 0 | N/A | N/A | 0 | N/A | N/A | N/A | | 2018 * | 5 | 1.000 | 1.400 | 1.400 | 0.400 | 0 | N/A | N/A | 5 | 1.000 | 1.400 | N/A | -| 2019 | 189 | 2.058 | 2.921 | 2.153 | 0.862 | 15 | 2.933 | 2.867 | 174 | 1.983 | 2.925 | 0.483 | -| 2020 | 446 | 2.231 | 2.899 | 2.213 | 0.668 | 45 | 3.267 | 3.378 | 401 | 2.115 | 2.845 | 0.608 | -| 2021 | 409 | 1.751 | 2.973 | 2.205 | 1.222 | 30 | 2.900 | 3.800 | 379 | 1.660 | 2.908 | 0.496 | -| 2022 | 412 | 1.769 | 2.507 | 2.121 | 0.738 | 71 | 2.225 | 3.042 | 341 | 1.674 | 2.396 | 0.440 | -| 2023 | 353 | 1.550 | 2.680 | 2.215 | 1.130 | 59 | 2.169 | 3.254 | 294 | 1.425 | 2.565 | 0.338 | -| 2024 | 455 | 1.686 | 2.578 | 1.974 | 0.892 | 55 | 2.545 | 3.091 | 400 | 1.567 | 2.507 | 0.385 | -| 2025 | 429 | 1.697 | 2.322 | 2.231 | 0.625 | 78 | 2.487 | 3.269 | 351 | 1.521 | 2.111 | 0.589 | -| 2026 | 146 | 2.062 | 2.253 | 2.329 | 0.192 | 26 | 2.500 | 2.769 | 120 | 1.967 | 2.142 | 0.410 | +| 2019 | 195 | 2.046 | 2.913 | 2.138 | 0.867 | 0 | N/A | N/A | 195 | 2.046 | 2.913 | 0.491 | +| 2020 | 469 | 2.228 | 2.906 | 2.262 | 0.678 | 0 | N/A | N/A | 469 | 2.228 | 2.906 | 0.603 | +| 2021 | 425 | 1.755 | 2.976 | 2.240 | 1.221 | 0 | N/A | N/A | 425 | 1.755 | 2.976 | 0.503 | +| 2022 | 446 | 1.800 | 2.525 | 2.161 | 0.724 | 0 | N/A | N/A | 446 | 1.800 | 2.525 | 0.429 | +| 2023 | 365 | 1.575 | 2.690 | 2.238 | 1.115 | 0 | N/A | N/A | 365 | 1.575 | 2.690 | 0.346 | +| 2024 | 469 | 1.680 | 2.582 | 1.985 | 0.902 | 0 | N/A | N/A | 469 | 1.680 | 2.582 | 0.391 | +| 2025 | 455 | 1.695 | 2.332 | 2.253 | 0.637 | 0 | N/A | N/A | 455 | 1.695 | 2.332 | 0.592 | +| 2026 | 195 | 2.015 | 2.405 | 2.331 | 0.390 | 0 | N/A | N/A | 195 | 2.015 | 2.405 | 0.335 | > * Years with <50 scored motions; confidence intervals are wider or N/A. @@ -76,42 +79,34 @@ and material extremity. | 2016 | N/A | N/A | 6 | All | | 2017 | N/A | N/A | 0 | All | | 2018 | N/A | N/A | 5 | All | -| 2019 | 0.483 | 0.000000 | 189 | All | -| | 0.844 | 0.000077 | 15 | Migration | -| | 0.471 | 0.000000 | 174 | Non-migration | -| 2020 | 0.608 | 0.000000 | 446 | All | -| | 0.447 | 0.002064 | 45 | Migration | -| | 0.610 | 0.000000 | 401 | Non-migration | -| 2021 | 0.496 | 0.000000 | 409 | All | -| | 0.597 | 0.000501 | 30 | Migration | -| | 0.446 | 0.000000 | 379 | Non-migration | -| 2022 | 0.440 | 0.000000 | 412 | All | -| | 0.543 | 0.000001 | 71 | Migration | -| | 0.344 | 0.000000 | 341 | Non-migration | -| 2023 | 0.338 | 0.000000 | 353 | All | -| | 0.501 | 0.000052 | 59 | Migration | -| | 0.222 | 0.000124 | 294 | Non-migration | -| 2024 | 0.385 | 0.000000 | 455 | All | -| | 0.086 | 0.531026 | 55 | Migration | -| | 0.376 | 0.000000 | 400 | Non-migration | -| 2025 | 0.589 | 0.000000 | 429 | All | -| | 0.558 | 0.000000 | 78 | Migration | -| | 0.445 | 0.000000 | 351 | Non-migration | -| 2026 | 0.410 | 0.000000 | 146 | All | -| | 0.421 | 0.032410 | 26 | Migration | -| | 0.317 | 0.000411 | 120 | Non-migration | +| 2019 | 0.491 | 0.000000 | 195 | All | +| | 0.491 | 0.000000 | 195 | Non-migration | +| 2020 | 0.603 | 0.000000 | 469 | All | +| | 0.603 | 0.000000 | 469 | Non-migration | +| 2021 | 0.503 | 0.000000 | 425 | All | +| | 0.503 | 0.000000 | 425 | Non-migration | +| 2022 | 0.429 | 0.000000 | 446 | All | +| | 0.429 | 0.000000 | 446 | Non-migration | +| 2023 | 0.346 | 0.000000 | 365 | All | +| | 0.346 | 0.000000 | 365 | Non-migration | +| 2024 | 0.391 | 0.000000 | 469 | All | +| | 0.391 | 0.000000 | 469 | Non-migration | +| 2025 | 0.592 | 0.000000 | 455 | All | +| | 0.592 | 0.000000 | 455 | Non-migration | +| 2026 | 0.335 | 0.000002 | 195 | All | +| | 0.335 | 0.000002 | 195 | Non-migration | --- ## 6. Correlation Change Pre vs Post 2024 -**Pre-2024 mean r(stijl,mat):** 0.473 +**Pre-2024 mean r(stijl,mat):** 0.475 -**Post-2024 mean r(stijl,mat):** 0.461 +**Post-2024 mean r(stijl,mat):** 0.439 -**Change test (Mann-Whitney):** U=9.000, p=0.786 +**Change test (Mann-Whitney):** U=10.000, p=0.571 -**Interpretation:** No significant change in stijl-material correlation (U=9.0, p=0.7857) +**Interpretation:** No significant change in stijl-material correlation (U=10.0, p=0.5714) A significant change in the per-year stijl-material correlation would suggest that the relationship between the two dimensions itself shifted across the break period — @@ -122,9 +117,9 @@ impact, the correlation would decrease. ## 7. Gap Trajectory Interpretation -- **Pre-2024 mean gap:** 0.813 -- **Post-2024 mean gap:** 0.570 -- **Gap change:** -0.243 +- **Pre-2024 mean gap:** 0.810 +- **Post-2024 mean gap:** 0.643 +- **Gap change:** -0.167 A widening gap (increasing material > stylistic) would indicate that right-wing motions became less stylistically extreme but maintained or increased their material impact — @@ -142,28 +137,58 @@ trend is an accurate summary (no masked divergence). | Domain | Pre Mean Stijl | Pre Mean Mat | Post Mean Stijl | Post Mean Mat | Pre Gap | Post Gap | Pre r | Post r | |--------|---------------|-------------|----------------|---------------|---------|----------|-------|--------| -| Migration | 2.699 | 3.268 | 2.511 | 3.043 | 0.569 | 0.532 | 0.586 | 0.355 | -| Non-migration | 1.646 | 2.482 | 1.685 | 2.253 | 0.836 | 0.568 | 0.419 | 0.380 | +| Migration | N/A | N/A | N/A | N/A | N/A | N/A | N/A | N/A | +| Non-migration | 1.725 | 2.535 | 1.797 | 2.440 | 0.810 | 0.643 | 0.475 | 0.439 | + +--- + +## 9. Gravity-Weighted Trends (Right-Wing) + +Yearly means for right-wing motions filtered by material impact thresholds. +M≥3 = motions with substantive material impact (score ≥ 3). +M≥4 = motions with fundamental material impact (score ≥ 4). + +| Year | N (all RW) | M≥3 N | M≥4 N | Stijl (all) | Stijl M≥3 | Stijl M≥4 | Mat (all) | Mat M≥3 | Mat M≥4 | +|------|-----------|-------|-------|-------------|-----------|-----------|-----------|---------|---------| +| 2016 | 6 | 2 | 1 | 1.667 | 3.000 | 3.000 | 2.333 | 3.500 | 4.000 | +| 2017 | 0 | 0 | 0 | N/A | N/A | N/A | N/A | N/A | N/A | +| 2018 | 5 | 0 | 0 | 1.000 | N/A | N/A | 1.400 | N/A | N/A | +| 2019 | 195 | 125 | 49 | 2.046 | 2.344 | 2.755 | 2.913 | 3.480 | 4.224 | +| 2020 | 469 | 307 | 109 | 2.228 | 2.603 | 3.119 | 2.906 | 3.450 | 4.266 | +| 2021 | 425 | 270 | 143 | 1.755 | 1.978 | 2.364 | 2.976 | 3.670 | 4.266 | +| 2022 | 446 | 216 | 60 | 1.800 | 2.065 | 2.467 | 2.525 | 3.324 | 4.167 | +| 2023 | 365 | 188 | 96 | 1.575 | 1.787 | 2.052 | 2.690 | 3.601 | 4.177 | +| 2024 | 469 | 259 | 75 | 1.680 | 1.965 | 2.067 | 2.582 | 3.320 | 4.107 | +| 2025 | 455 | 157 | 45 | 1.695 | 2.287 | 2.911 | 2.332 | 3.369 | 4.289 | +| 2026 | 195 | 76 | 15 | 2.015 | 2.263 | 3.000 | 2.405 | 3.211 | 4.067 | + +| Bucket | Pre-2024 Mean Stijl | Pre-2024 Mean Mat | Post-2024 Mean Stijl | Post-2024 Mean Mat | +|--------|-------------------|-------------------|---------------------|-------------------| +| All RW | 1.725 | 2.535 | 1.797 | 2.440 | +| M≥3 | 2.296 | 3.504 | 2.172 | 3.300 | +| M≥4 | 2.626 | 4.183 | 2.659 | 4.154 | --- -## 9. Figure +## 10. Figure ![2D Extremity Temporal Figure](extremity_2d_temporal_figure.png) **Figure panels:** - **Top panel:** Yearly mean stylistic (red) and material (blue) extremity scores with 95% bootstrap confidence intervals. Grey dashed line = original single-dimension - `text_score` for comparison. -- **Middle panel:** Gap trajectory (material minus stylistic) for all domains, migration, + `text_score` for comparison. Gold/orange lines show material impact for M≥3 and M≥4 subsets. +- **Second panel:** Gap trajectory (material minus stylistic) for all domains, migration, and non-migration. Positive gap = material impact exceeds stylistic extremity. A widening gap indicates increasing divergence between dimensions. -- **Bottom panel:** Per-year Pearson correlation between stylistic and material scores. +- **Third panel:** Per-year Pearson correlation between stylistic and material scores. Declining correlation over time suggests the two dimensions are decoupling. +- **Fourth panel:** All-motion (dashed) vs right-wing (solid) comparison for both stylistic + and material dimensions. Shows how right-wing trends compare to the full motion landscape. --- -## 10. Limitations +## 11. Limitations - **Yearly resolution:** Year-level aggregation necessarily smooths within-year trends. The quarterly framework from U1 provides finer resolution for other metrics. @@ -178,13 +203,40 @@ trend is an accurate summary (no masked divergence). --- -## 11. Conclusion +## 12. All-Motion Comparison + +Yearly means for ALL motions (from `extremity_scores_all`) compared to right-wing-only means. +This provides context for whether right-wing trends reflect party-specific dynamics or broader +parliamentary trends. + +| Year | N (all) | All Stijl | All Mat | N (RW) | RW Stijl | RW Mat | Diff Stijl | Diff Mat | +|------|---------|-----------|---------|--------|----------|--------|------------|----------| +| 2016 | 162 | 1.037 | 1.796 | 6 | 1.667 | 2.333 | 0.630 | 0.537 | +| 2017 | 126 | 1.024 | 1.302 | 0 | N/A | N/A | N/A | N/A | +| 2018 | 124 | 1.000 | 1.540 | 5 | 1.000 | 1.400 | 0.000 | -0.140 | +| 2019 | 3374 | 1.276 | 2.055 | 195 | 2.046 | 2.913 | 0.770 | 0.858 | +| 2020 | 4223 | 1.370 | 2.146 | 469 | 2.228 | 2.906 | 0.858 | 0.760 | +| 2021 | 4283 | 1.378 | 2.144 | 425 | 1.755 | 2.976 | 0.378 | 0.832 | +| 2022 | 4115 | 1.379 | 2.154 | 446 | 1.800 | 2.525 | 0.422 | 0.371 | +| 2023 | 3272 | 1.356 | 2.250 | 365 | 1.575 | 2.690 | 0.219 | 0.441 | +| 2024 | 3965 | 1.371 | 2.203 | 469 | 1.680 | 2.582 | 0.309 | 0.380 | +| 2025 | 3712 | 1.420 | 2.174 | 455 | 1.695 | 2.332 | 0.274 | 0.158 | +| 2026 | 2214 | 1.383 | 1.747 | 195 | 2.015 | 2.405 | 0.633 | 0.658 | + +| Period | All Stijl | All Mat | RW Stijl | RW Mat | Stijl Δ | Mat Δ | +|--------|-----------|---------|----------|--------|---------|-------| +| Pre-2024 | 1.227 | 1.923 | 1.725 | 2.535 | 0.497 | 0.612 | +| Post-2024 | 1.391 | 2.041 | 1.797 | 2.440 | 0.405 | 0.399 | + +--- + +## 13. Conclusion -The overall stijl-materieel correlation is r=0.470 (p=0.000000), +The overall stijl-materieel correlation is r=0.472 (p=0.000000), consistent with the aggregate finding of r≈0.47. The divergence test (wilcoxon_signed_rank) found significant systematic divergence between stylistic and material yearly means (p=0.002). -The pre/post correlation change analysis no significant change in stijl-material correlation (u=9.0, p=0.7857). +The pre/post correlation change analysis no significant change in stijl-material correlation (u=10.0, p=0.5714). -The gap (material minus stylistic) narrowed from 0.813 pre-2024 to 0.570 post-2024. \ No newline at end of file +The gap (material minus stylistic) narrowed from 0.810 pre-2024 to 0.643 post-2024. \ No newline at end of file diff --git a/reports/overton_window/extremity_2d_temporal_figure.png b/reports/overton_window/extremity_2d_temporal_figure.png index a96a92f..6016353 100644 Binary files a/reports/overton_window/extremity_2d_temporal_figure.png and b/reports/overton_window/extremity_2d_temporal_figure.png differ diff --git a/reports/overton_window/findings_report.md b/reports/overton_window/findings_report.md deleted file mode 100644 index d0a2606..0000000 --- a/reports/overton_window/findings_report.md +++ /dev/null @@ -1,185 +0,0 @@ -# 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.** Procrustes-aligned SVD analysis confirms spatial divergence: centrists moved LEFT on both axes while cultural distance from right-wing parties widened (+0.146). Content extremity did not increase (d=−0.09), but LLM scores have known biases. The shift is centered on the migration domain. - ---- - -## 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](breakpoint_figure_1.png) - -### Pass rate excluded - -Pass rate (96%+ both periods) is excluded — Dutch parliament passes nearly all motions, making it a non-diagnostic metric. Centrist support is the primary signal. - -### Domain decomposition - -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: Gradient persists - -Centrist support rose across all extremity buckets post-2024, but the gradient persists: centrists still differentiate by extremity level, just at a consistently higher baseline. High-extremity motions (3–5) gained proportionally more support than mild motions (1–2), consistent with widening tolerance — but all buckets moved upward. - ---- - -## 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](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:** LLM audit shows 75% agreement (15/20 motions) with systematic biases: LLM overrates anti-institutional language and migration-adjacent content. A flat trend may partially reflect these biases rather than genuine content stability. See two-dimensional rescoring (deferred). - ---- - -## 4. Indicator 3: SVD Spatial Drift — Acceptance Without Conversion - -### Methodology: Procrustes-aligned PCA - -Raw per-window SVD axes re-orient independently each year, causing 9/10 consecutive window pairs to fail axis stability (Spearman ρ < 0.7). To enable cross-window comparison, we use the same alignment pipeline as the Explorer UI compass: - -1. Zero-pad party vectors to max dimension across all windows -2. Chain Procrustes orthogonal rotation (each window to the previous aligned one) to preserve relative structure -3. Global PCA on the stacked aligned matrix for a common 2D reference frame -4. Flip-correction per component using canonical left/right parties - -This ensures all positions live in the same coordinate system — positional changes reflect genuine voting behavior shifts, not axis re-orientation artifacts. - -### Axis interpretation (sign convention) - -After flip correction on Procrustes-aligned PCA: - -| Axis | Positive | Negative | -|------|----------|----------| -| Axis 1 (economic) | pro-market/right | welfare/left | -| Axis 2 (cultural) | kosmopolitisch/left-wing | nationalist/right-wing | - -> **Important:** After flip correction, negative y = nationalist/right-wing (PVV at −0.56, FVD at −0.36). Positive y = kosmopolitisch/left-wing (Volt at +0.27, GL-PvdA at +0.21). This is the *opposite* of what the raw `SVD_THEMES[2]` label says, because PCA axes are flip-corrected to align with canonical left/right parties. SVD labels reflect voting patterns, not semantic content. - -### Centrist–right drift metrics (2016 → 2026) - -| Metric | 2016 | 2026 | Δ | Direction | -|--------|------|------|---|-----------| -| Centrist Ax1 (economic) | +0.340 | +0.117 | −0.223 | LEFT (more welfare) | -| Centrist Ax2 (cultural) | +0.010 | +0.091 | +0.081 | LEFT (more kosmopolitisch) | -| Right Ax2 (cultural) | −0.272 | −0.337 | −0.065 | RIGHT (more nationalist) | -| Cultural gap (\|C−R\|) | 0.282 | 0.428 | +0.146 | DIVERGENCE | - -![SVD Drift Chart](svd_drift_chart.png) - -### Central tension: Acceptance without conversion - -Centrist voting support for right-wing motions surged (d=+0.85 opposition-only), yet Procrustes-aligned SVD analysis shows: - -- Centrists moved **LEFT on both axes** (more welfare-oriented, more kosmopolitisch) -- Right-wing parties moved **further RIGHT culturally** (more nationalist) -- The centrist–right cultural distance **widened** from 0.282 to 0.428 (+0.146) - -This pattern — greater political support combined with greater ideological distance — resolves as **"acceptance without conversion."** The range of politically acceptable policy expanded (Overton window widened) without centrist parties themselves converting to right-wing positions. Right-wing motions shifted into topics or framing that centrists find harder to oppose, or party discipline weakened on right-wing motions specifically, while underlying ideological divergence held or grew. - ---- - -## 5. Synthesis - -### Tier 1 — Converging evidence (strong) - -1. **Centrist support surged post-2024:** Cohen's d = +0.68 overall, d = +0.85 for opposition-only motions. Not a coalition artifact. -2. **Migration is the primary domain:** Migration motions gained +0.233 in centrist support vs. +0.076 for non-migration. Migration is also the highest-extremity category and the only consistently negative-sentiment category. -3. **Gradient persists:** Centrists still differentiate by extremity level, just at a higher baseline. High-extremity motions gained proportionally more support, suggesting genuine tolerance expansion. - -### Tier 2 — Tension (explanatory, not contradictory) - -**Acceptance without conversion.** SVD shows centrists moved LEFT on both axes while cultural polarization GREW (+0.146). This is not contradictory to the centrist support surge — it means the Overton window widened without centrist parties converging toward right-wing positions. Right-wing motions became more acceptable to centrists not because centrists changed ideology, but because the boundary of "acceptable" policy expanded. Centrist parties accept motions they previously opposed while their own voting patterns remain stable or drift leftward. - -**Mechanism analysis** of the 24 right-wing motions with highest centrist support post-2024 reveals HOW right-wing motions gain centrist backing without ideological conversion: - -| Mechanism | Count | % | -|-----------|-------|---| -| Consensus framing (shared values: safety, efficiency, pragmatism) | 8 | 33% | -| Institutional/rule-of-law (oversight, transparency, anti-corruption) | 5 | 21% | -| Welfare/service expansion (protect vulnerable groups) | 4 | 17% | -| Procedural/technical | 3 | 13% | -| Local/constituency | 1 | 4% | -| Coalition alignment | 1 | 4% | -| Symbolic/declaratory | 1 | 4% | -| Targeted restriction | 1 | 4% | -| System dismantling | 0 | 0% | -| Crisis response | 0 | 0% | - -**Key finding:** The dominant pathway is *consensus framing* — right-wing motions package their requests in widely-shared values (public safety, economic competitiveness, energy transition pragmatism) stripping away partisan markers. Institutional/rule-of-law framing comes second: motions strengthening oversight or legal frameworks make centrist opposition untenable since these parties stake their identity on good governance. Critically, only 1 motion involves targeted rights restriction and **zero involve system dismantling** — the truly ideological right-wing agenda (asylum stops, treaty exits, fundamental institutional upheaval) does not gain centrist support. Right-wing influence flows not through conversion but through repackaging: speaking in vocabulary centrists already accept. - -### Tier 3 — Weak/noisy (updated with 2D findings) - -Content extremity trend is flat (d=−0.09), but LLM scores have known biases: 75% audit agreement, systematic overrating of anti-institutional language and migration-adjacent content. **Two-dimensional rescoring completed (n=117, stratified):** Pearson r=0.45 between stylistic extremity and material impact — moderate, confirming the dimensions are separable. Material impact averages 0.85 points above stylistic (2.86 vs. 2.01), with 36.8% of motions using restrained language to mask high-impact policies. The original single-score LLM conflated inflammatory phrasing with substantive policy effect, explaining ~25% of the audit disagreement. A flat single-dimension trend may reflect this conflation rather than genuine content stability. - -### Uncertainty hierarchy - -| Evidence Level | Indicator | Status | -|---------------|-----------|--------| -| **Strong** | Centrist support surge (opposition-controlled) | Confirmed | -| **Strong** | Spatial divergence — acceptance without conversion | Confirmed | -| **Moderate** | Migration-specificity of the shift | Confirmed | -| **Inconclusive** | Extremity-stratified tolerance shift | Gradient persists, baseline-shifted | -| **Weak** | Content extremity trend | No increase (LLM biases, 75% audit; 2D rescoring r=0.45) | - ---- - -## 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. Two-dimensional rescoring confirms stylistic and material dimensions are separable (r=0.45). The original single score conflates language radicalism with policy impact. -- **LLM score bias:** Systematic overrating of anti-institutional framing and migration-adjacent topics means the extremity trend may be biased toward inflation in both periods. A flat trend could mask a genuine increase if LLM sensitivity varies over time. -- **Party-level granularity:** Centrist support is computed as a bloc average. Individual party trajectories (e.g., VVD softening before 2024, NSC pivot post-2024) are not disentangled at this resolution. -- **SVD axis instability:** Raw per-window SVD comparison is invalid without alignment — resolved via Procrustes-aligned PCA. Spatial divergence conclusion depends on this alignment. -- **Coalition composition:** Hardcoded per year. 2024 is ambiguous (Rutte IV until July, Schoof thereafter). Early 2024 motions may be miscoded. -- **Submitter party identification:** Parsed from motion title prefixes. ~10% of motions have non-standard titles (bills, amendments) and are excluded from opposition-only analysis. -- **Pass rate baseline:** Computed across motions with recorded votes. Unanimous consent motions are excluded, potentially biasing baseline upward. Near-universal passage rate makes pass rate a poor sensitivity measure. - ---- - -## 7. Figures - -1. `breakpoint_figure_1.png` — Centrist support over time (all RW, opposition-only, migration, non-migration, + baseline) -2. `breakpoint_figure_2.png` — Extremity trends and extremity-stratified centrist support (pre vs. post 2024) -3. `svd_drift_chart.png` — Procrustes-aligned centrist center trajectory (see Section 4) - ---- - -## 8. Next Steps - -1. **Two-dimensional extremity rescoring:** **IN PROGRESS.** Stratified sample (n=117) scored with dual-dimension prompt via subagent dispatches. Pearson r=0.45 — dimensions are separable. Material impact averages 0.85 points above stylistic. Next: rescore the remaining ~2,870 motions at higher batch size to enable 2D extremity-stratified analyses, or re-run the full pipeline if correlation sufficient to recalibrate. - -2. **Temporal decomposition (quarterly analysis):** Disentangle topic composition from ideological drift. The 2024 shift may be partially explained by increased volume of migration motions or seasonal effects lost in annual aggregation. - -3. **Mechanism analysis:** **DONE.** 24 post-2024 high-centrist-support motions classified across 10 mechanism types. Dominant pathways: consensus framing (33%), institutional/rule-of-law (21%), welfare/service expansion (17%). Targeted restrictions and system dismantling near zero — right-wing gains centrist support by repackaging in centrist-friendly language, not by converting centrists. diff --git a/reports/overton_window/left_wing_response.md b/reports/overton_window/left_wing_response.md index 676a2f4..c4d8bce 100644 --- a/reports/overton_window/left_wing_response.md +++ b/reports/overton_window/left_wing_response.md @@ -1,5 +1,7 @@ # Left-Wing Response to Right-Wing Motions +> **Part of the Overton Window Analysis.** See the [synthesis report](overton_window_synthesis.md) for the integrated narrative, or the [interactive article](overton_window.qmd) for the full story with charts. + **Goal:** Determine whether the centrist support surge reflects right-wing moderation, centrist acceptance, or left-wing opposition hardening. @@ -23,7 +25,7 @@ moderation, centrist acceptance, or left-wing opposition hardening. | 2023 | 365 | 0.1779 | 0.255 | +0.077 | | 2024 | 469 | 0.2441 | 0.595 | +0.351 | | 2025 | 455 | 0.2015 | 0.474 | +0.272 | -| 2026 | 151 | 0.1594 | 0.334 | +0.174 | +| 2026 | 195 | 0.1675 | 0.376 | +0.209 | > Note: 2016 (n=6) and 2018 (n=5) have very small sample sizes and @@ -39,22 +41,22 @@ moderation, centrist acceptance, or left-wing opposition hardening. | Metric | Pre-2024 Mean | Post-2024 Mean | Δ | Cohen d | |--------|--------------|---------------|-----|----------| -| Left Support (MP) | 0.2680 | 0.2017 | -0.0663 | -0.75 | -| Centrist Support | 0.425 | 0.468 | +0.042 | +0.18 | -| Polarization Gap | 0.157 | 0.266 | +0.109 | — | +| Left Support (MP) | 0.2680 | 0.2044 | -0.0636 | -0.72 | +| Centrist Support | 0.425 | 0.482 | +0.057 | +0.25 | +| Polarization Gap | 0.157 | 0.277 | +0.120 | — | ### Excluding low-N years (<50 motions: 2016, 2018) | Metric | Pre-2024 Mean | Post-2024 Mean | Δ | Cohen d | |--------|--------------|---------------|-----|----------| -| Left Support (MP) | 0.2129 | 0.2017 | -0.0112 | — | -| Centrist Support | 0.262 | 0.468 | +0.206 | +1.89 | -| Polarization Gap | 0.049 | 0.266 | +0.217 | — | +| Left Support (MP) | 0.2129 | 0.2044 | -0.0085 | — | +| Centrist Support | 0.262 | 0.482 | +0.220 | +2.28 | +| Polarization Gap | 0.049 | 0.277 | +0.228 | — | **Interpretation:** -- Centrist support surged from 26.2% to 46.8% (d=+1.89). -- Left support shifted from 21.3% to 20.2% (d=-0.75). -- The polarization gap **widened** by +0.217, driven predominantly by the centrist acceptance surge rather than left-wing hardening. +- Centrist support surged from 26.2% to 48.2% (d=+2.28). +- Left support shifted from 21.3% to 20.4% (d=-0.72). +- The polarization gap **widened** by +0.228, driven predominantly by the centrist acceptance surge rather than left-wing hardening. --- @@ -65,11 +67,11 @@ A party's support ratio is the fraction of its MPs voting 'voor' on classified r | Party | Pre-2024 Mean | Post-2024 Mean | Δ | Pre N MPs (avg) | Post N MPs (avg) | |-------|--------------|---------------|-----|-----------------|------------------| -| SP | 0.2945 | 0.2186 | -0.0759 | 297 | 405 | -| GroenLinks-PvdA | 0.2610 | 0.1504 | -0.1106 | 587 | 550 | -| PvdD | 0.1357 | 0.0668 | -0.0689 | 283 | 372 | -| Volt | 0.1122 | 0.2415 | +0.1293 | 387 | 361 | -| DENK | 0.4007 | 0.2780 | -0.1227 | 319 | 375 | +| SP | 0.2945 | 0.2124 | -0.0821 | 297 | 420 | +| GroenLinks-PvdA | 0.2610 | 0.1580 | -0.1031 | 587 | 565 | +| PvdD | 0.1357 | 0.0682 | -0.0675 | 283 | 387 | +| Volt | 0.1122 | 0.2493 | +0.1371 | 387 | 375 | +| DENK | 0.4007 | 0.2815 | -0.1192 | 319 | 389 | --- @@ -81,10 +83,10 @@ Non-migration = all other categories. | Domain | Period | Left Support | Centrist Support | Gap | N | |--------|--------|-------------|-----------------|-----|---| -| migration | Pre-2024 | 0.0571 | 0.146 | +0.089 | 233 | -| migration | Post-2024 | 0.1062 | 0.361 | +0.255 | 171 | -| non-migration | Pre-2024 | 0.2824 | 0.435 | +0.153 | 1678 | -| non-migration | Post-2024 | 0.2192 | 0.487 | +0.268 | 904 | +| migration | Pre-2024 | N/A | N/A | N/A | 0 | +| migration | Post-2024 | N/A | N/A | N/A | 0 | +| non-migration | Pre-2024 | 0.2680 | 0.425 | +0.157 | 1911 | +| non-migration | Post-2024 | 0.2044 | 0.482 | +0.277 | 1119 | --- @@ -105,7 +107,7 @@ Non-migration = all other categories. | 2023 | 142 | 410 | 0.3463 | | 2024 | 119 | 497 | 0.2394 | | 2025 | 136 | 564 | 0.2411 | -| 2026 | 27 | 154 | 0.1753 | +| 2026 | 31 | 198 | 0.1566 | ### GroenLinks-PvdA @@ -120,7 +122,7 @@ Non-migration = all other categories. | 2023 | 29 | 804 | 0.0361 | | 2024 | 131 | 621 | 0.2110 | | 2025 | 106 | 859 | 0.1234 | -| 2026 | 20 | 171 | 0.1170 | +| 2026 | 30 | 215 | 0.1395 | ### PvdD @@ -135,7 +137,7 @@ Non-migration = all other categories. | 2023 | 62 | 396 | 0.1566 | | 2024 | 45 | 483 | 0.0932 | | 2025 | 39 | 481 | 0.0811 | -| 2026 | 4 | 153 | 0.0261 | +| 2026 | 6 | 197 | 0.0305 | ### Volt @@ -150,7 +152,7 @@ Non-migration = all other categories. | 2023 | 36 | 372 | 0.0968 | | 2024 | 143 | 474 | 0.3017 | | 2025 | 116 | 463 | 0.2505 | -| 2026 | 25 | 145 | 0.1724 | +| 2026 | 37 | 189 | 0.1958 | ### DENK @@ -165,7 +167,7 @@ Non-migration = all other categories. | 2023 | 137 | 378 | 0.3624 | | 2024 | 137 | 481 | 0.2848 | | 2025 | 125 | 490 | 0.2551 | -| 2026 | 45 | 153 | 0.2941 | +| 2026 | 60 | 197 | 0.3046 | --- @@ -173,23 +175,23 @@ Non-migration = all other categories. ## 6. Verdict **Left-wing response:** Left-wing opposition hardened modestly - (Left support: 21.3% → 20.2%, Δ = -1.1%) + (Left support: 21.3% → 20.4%, Δ = -0.8%) **Centrist response:** **Centrist acceptance surged** (large increase in support) - (Centrist support: 26.2% → 46.8%, Δ = +20.6%, d=+1.89) + (Centrist support: 26.2% → 48.2%, Δ = +22.0%, d=+2.28) **Polarization gap trajectory:** Pre-2024 mean gap: 0.049 - Post-2024 mean gap: 0.266 - Delta: +0.217 + Post-2024 mean gap: 0.277 + Delta: +0.228 -The polarization gap **widened** by +0.217, driven predominantly by the centrist acceptance surge rather than left-wing hardening. +The polarization gap **widened** by +0.228, driven predominantly by the centrist acceptance surge rather than left-wing hardening. **Key finding:** The centrist acceptance surge is the dominant force. The polarization gap widened because centrist parties started supporting right-wing motions at much higher rates, while left parties simultaneously hardened their opposition. The centrist shift is -18.3x larger in magnitude +25.9x larger in magnitude than the left-wing shift. Right-wing moderation (content extremity decline) likely contributed to both effects: making motions more palatable for centrists while simultaneously creating a strategic environment where diff --git a/reports/overton_window/left_wing_response_figure.png b/reports/overton_window/left_wing_response_figure.png index 064de7d..ba28f26 100644 Binary files a/reports/overton_window/left_wing_response_figure.png and b/reports/overton_window/left_wing_response_figure.png differ diff --git a/reports/overton_window/mechanism_classification.md b/reports/overton_window/mechanism_classification.md index 6aade67..ceeda9d 100644 --- a/reports/overton_window/mechanism_classification.md +++ b/reports/overton_window/mechanism_classification.md @@ -1,5 +1,7 @@ # Mechanism Classification Report +> **Part of the Overton Window Analysis.** See the [synthesis report](overton_window_synthesis.md) for the integrated narrative, or the [interactive article](overton_window.qmd) for the full story with charts. + **Sample:** 200 motions (stratified: 50 pre-2024, 150 post-2024) **Classified:** 200 motions | **Unclassified:** 0 diff --git a/reports/overton_window/mechanism_validation.md b/reports/overton_window/mechanism_validation.md index 595d240..f0b8949 100644 --- a/reports/overton_window/mechanism_validation.md +++ b/reports/overton_window/mechanism_validation.md @@ -1,5 +1,7 @@ # Mechanism Classification Validation Report +> **Part of the Overton Window Analysis.** See the [synthesis report](overton_window_synthesis.md) for the integrated narrative, or the [interactive article](overton_window.qmd) for the full story with charts. + ## 1. Inter-Rater Reliability - **Motions compared:** 200 diff --git a/reports/overton_window/overton_report.html b/reports/overton_window/overton_report.html new file mode 100644 index 0000000..5d4c6dc --- /dev/null +++ b/reports/overton_window/overton_report.html @@ -0,0 +1,555 @@ + + + + + +Overton Window Analysis | Dutch Parliament 2016-2026 + + + +
+ + +
+

The Overton Window in the Dutch Parliament

+
A gravity-model analysis of 29,591 parliamentary motions from 2016 to 2026 reveals how the window of acceptable policy debate shifted after the 2024 Schoof cabinet formation.
+
Analysis based on voting records · Data: motions.db · Period: 2016–2026
+
+ + +

What Is the Overton Window

+

The Overton window describes the range of policy ideas that are politically acceptable at a given time. In the Dutch parliamentary context, we operationalise this by measuring centrist support for motions at varying levels of policy extremity. The core question is not whether extreme motions pass, but whether centrist parties are willing to support them at all — a signal that a policy position has entered the realm of acceptable debate.

+

We model acceptability using a gravity framework: each motion is assigned a gravity level (M1 through M5) based on its combined stylistic and material extremity. Higher gravity levels correspond to more extreme motions. The formation of the Schoof cabinet in July 2024 serves as the watershed. By comparing centrist support before and after this date, we can measure whether the Overton window shifted — and if so, by how much.

+ + +

Methodology

+

Each motion was scored on two independent dimensions by an LLM-based classifier: stijl (rhetorical extremity, 1–5) and materieel (policy impact, 1–5). The gravity level is derived from the combined scores. Centrist support is measured as the fraction of centrist parties that voted in favour. The canonical definition uses 4 strict centrist parties: D66, CDA, CU, and NSC. A broader all-party model (including VVD, BBB, and other non-extreme parties) is shown for comparison.

+

The dataset contains 29,591 motions. The pre-2024 period (January 2016 to June 2024) covers 21,695 motions; the post-2024 period (July 2024 to present) covers 7,875 motions. Mean stylistic extremity: 1.36 (on a 1–5 scale). Mean material extremity: 2.12. The Pearson correlation between the two dimensions is r = 0.43, confirming that style and substance are separable but moderately related.

+ + +

Gravity-Controlled Analysis

+

Centrist support (all-party definition) by gravity level, before and after July 2024. As expected, higher gravity levels show lower centrist support. The post-2024 window shows increased centrist support at levels M3 and above, consistent with a rightward shift in the Overton window.

+ +
+
+ Pre-2024 + Post-2024 +
+ +
+
+ M1 — Lowest extremity + Pre: 4,495 · Post: 2,068 +
+
+
Pre: 0.715 / Post: 0.655
+
+
+ +
+
+ M2 + Pre: 10,979 · Post: 3,698 +
+
+
Pre: 0.614 / Post: 0.632
+
+
+ +
+
+ M3 + Pre: 4,984 · Post: 1,730 +
+
+
Pre: 0.423 / Post: 0.449
+
+
+ +
+
+ M4 + Pre: 1,076 · Post: 346 +
+
+
Pre: 0.267 / Post: 0.278
+
+
+ +
+
+ M5 — Highest extremity + Pre: 161 · Post: 33 +
+
+
Pre: 0.138 / Post: 0.229
+
+
+
+ +
+ Interpretation. The gravity ranking is sensible: higher gravity levels consistently show lower centrist support. Post-2024, centrist support increased at levels M3 through M5, with the largest relative gain at M5 (from 0.138 to 0.229). At M1, centrist support actually decreased slightly post-2024, suggesting that the window shift primarily affected more extreme motions rather than broadening consensus on low-extremity proposals. +
+ + +

Strict 4-Party Centrist Model

+

Using the strict centrist definition (D66, CDA, CU, NSC), centrist support values are lower across all gravity levels, as expected. The strict model isolates the behaviour of the ideological centre without dilution by adjacent parties.

+ +
+
+ Pre-2024 + Post-2024 +
+ +
+
+ M1 — Lowest extremity + Pre: 4,495 · Post: 2,068 +
+
+
Pre: 0.718 / Post: 0.647
+
+
+ +
+
+ M2 + Pre: 10,979 · Post: 3,698 +
+
+
Pre: 0.608 / Post: 0.659
+
+
+ +
+
+ M3 + Pre: 4,984 · Post: 1,730 +
+
+
Pre: 0.391 / Post: 0.475
+
+
+ +
+
+ M4 + Pre: 1,076 · Post: 346 +
+
+
Pre: 0.189 / Post: 0.238
+
+
+ +
+
+ M5 — Highest extremity + Pre: 161 · Post: 33 +
+
+
Pre: 0.044 / Post: 0.101
+
+
+
+ +
+ Interpretation. Under the strict 4-party model, cs_strict values are indeed lower than the all-party figures. At M5, the centrist core went from cs_strict = 0.044 pre-2024 to 0.101 post-2024 — a 2.3x relative increase but still very low in absolute terms. This suggests that while the strict centrist core became more tolerant of extreme motions after the 2024 watershed, the absolute level of acceptance remains modest. The shift is real but not transformative for the most extreme proposals. +
+ + +

Example Motions

+

Three motions illustrate different patterns in the Overton window shift.

+ +
+
Motion 144 · Hidden Impact
+
Motie van het lid Eerdmans over zich inzetten voor juridische en politieke ruimte om asielprocedures buiten de EU te kunnen afhandelen
+

This motion proposes external processing of asylum procedures outside the EU. It scores stijl = 1 (neutral legal language, no rhetorical escalation) but materieel = 4 (fundamental policy reform). The modest stylistic framing masks the substantive ambition. The strict centrist support (cs_strict) rose from 0.00 pre-2024 to 1.00 post-2024 — a motion that no centrist party would touch before the Schoof cabinet became universally acceptable after.

+
+
Stijl
1
+
Materieel
4
+
CS strict pre
0.00
+
CS strict post
1.00
+
+
+ +
+
Motion 28109 · The Line
+
Motie van de leden Van Haga en Smolders over het Vluchtelingenverdrag uit 1951 opzeggen
+

This motion calls for withdrawing from the 1951 Refugee Convention. It scores stijl = 5 and materieel = 5 — maximum extremity on both dimensions. Despite the broader post-2024 shift in the Overton window, strict centrist support remained at 0.00 both before and after the watershed. Some positions remain outside the window of acceptability regardless of the political climate.

+
+
Stijl
5
+
Materieel
5
+
CS strict pre
0.00
+
CS strict post
0.00
+
+
+ +
+
Motion 306 · The Shift
+
Motie van de leden Boomsma en Van Zanten over maatregelen voor de vrijwillige terugkeer van Syriërs
+

This motion proposes measures for voluntary return of Syrians. It scores stijl = 2 and materieel = 3 — moderate on both dimensions. Strict centrist support went from 0.00 pre-2024 to 1.00 post-2024. This motion exemplifies the category of proposals that crossed the acceptability threshold after the political watershed, gaining full support from the centrist core where it previously had none.

+
+
Stijl
2
+
Materieel
3
+
CS strict pre
0.00
+
CS strict post
1.00
+
+
+ + +

Yearly Trend — M3+ Strict Centrist Support

+

Annual strict centrist support for motions at gravity level M3 and above. Years 2016–2018 have very low motion counts and should be interpreted with caution.

+ +
+
+
+
+
2016
+
+
+
+
2017
+
+
+
+
2018
+
+
+
+
2019
+
+
+
+
2020
+
+
+
+
2021
+
+
+
+
2022
+
+
+
+
2023
+
+
+
+
2024
+
+
+
+
2025
+
+
+
+
2026
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
YearCount (M3+)CS strictChange
2016170.705
201790.664-0.041
2018120.958+0.294
20198510.336-0.622
20201,1570.308-0.028
20211,2290.329+0.021
20221,1990.313-0.016
20231,0990.355+0.042
20241,2520.487+0.132
20251,1010.451-0.036
20264040.297-0.154
+ +
+ Note. The spike in 2018 (cs_strict = 0.958) is based on only 12 motions and should not be interpreted as a genuine shift in the Overton window. The low-N period from 2016 to 2018 reflects limited availability of digital motion records. The post-2024 peak (0.487 in 2024) followed by a decline through 2025–2026 suggests the shift may have been concentrated around the immediate Schoof cabinet formation period. +
+ + +

Right-Wing vs Other Parties

+

Comparing centrist support for motions proposed by right-wing parties (PVV, FVD, JA21, SGP) versus motions from all other parties, before and after July 2024.

+ +
+
+

Right-Wing Motions

+
0.384 → 0.620
+
Pre-20240.384
+
Post-20240.620
+
Change+0.236
+

N: 1,911 pre · 1,119 post

+
+
+

Other Party Motions

+
0.587 → 0.581
+
Pre-20240.587
+
Post-20240.581
+
Change-0.006
+

N: 17,768 pre · 8,772 post

+
+
+ +

Right-wing motions saw a substantial increase in centrist support after the 2024 watershed, rising from 0.384 to 0.620 — a gain of 0.236 points. In contrast, centrist support for motions from other parties remained essentially flat (0.587 pre vs 0.581 post). This asymmetric shift is the central finding of the analysis: the Overton window moved primarily on the right flank, with centrist parties becoming more willing to support proposals originating from the right wing, while their behaviour toward other party motions did not change.

+ + +

2D Distribution: Stijl vs Materieel

+

Each cell shows the count of motions at each combination of rhetorical extremity (stijl) and policy impact (materieel), with the percentage of the total dataset. The highlighted cell marks the highest concentration. The Pearson correlation between the two dimensions is r = 0.43.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Stijl \ Materieel12345
1 — Lowest
6,010
20.31%
11,428
38.62%
3,194
10.79%
391
1.32%
19
0.06%
2
442
1.49%
2,852
9.64%
2,880
9.73%
580
1.96%
47
0.16%
3
100
0.34%
360
1.22%
542
1.83%
308
1.04%
61
0.21%
4
14
0.05%
46
0.16%
96
0.32%
111
0.38%
49
0.17%
5 — Highest
2
0.01%
2
0.01%
7
0.02%
32
0.11%
18
0.06%
+ +
+ Interpretation. The modest correlation (r = 0.43) confirms that rhetorical extremity and policy impact are separable dimensions. Most motions cluster in the low-stijl / mid-materieel region: nearly 40% of all motions have stijl=1, materieel=2. Highly extreme motions (scores of 4 or 5 on either dimension) are rare, with only 61 out of 29,591 motions scoring at the maximum on both dimensions. +
+ + +

Key Takeaways

+
+ +
+ + + + +
+ + \ No newline at end of file diff --git a/reports/overton_window/overton_window.qmd b/reports/overton_window/overton_window.qmd new file mode 100644 index 0000000..2c09dba --- /dev/null +++ b/reports/overton_window/overton_window.qmd @@ -0,0 +1,689 @@ +--- +title: "Has the Overton Window Shifted?" +subtitle: "Acceptance Through Moderation in the Dutch Tweede Kamer (2016–2026)" +author: "Stemwijzer Analysis" +date: today +format: html +jupyter: python3 +--- + +```{python} +#| label: setup +#| include: false + +import duckdb +import pandas as pd +import numpy as np +import plotly.graph_objects as go +from plotly.subplots import make_subplots +from pathlib import Path + +ROOT = Path(".").resolve().parents[1] +DB_PATH = str(ROOT / "data" / "motions.db") + +con = duckdb.connect(DB_PATH, read_only=True) + +BREAK_YEAR = 2024 +PARTY_COLOURS = { + "VVD": "#1E73BE", "PVV": "#002366", "D66": "#00A36C", + "CDA": "#4CAF50", "CU": "#0288D1", "NSC": "#FF8F00", + "SGP": "#F4511E", "FVD": "#6A1B9A", "JA21": "#7B1FA2", + "BBB": "#8D6E63", "SP": "#E53935", "GroenLinks-PvdA": "#2E7D32", + "PvdD": "#43A047", "Volt": "#572AB7", "DENK": "#00897B", +} +``` + +> **Verdict:** The Overton window did not shift right. Right-wing parties +> moderated toward it. The shift may be temporary. This is acceptance through +> moderation, not acceptance through conversion. + +## Introduction + +Did the PVV's November 2023 election victory shift the Dutch Overton window? +The conventional narrative is clear: a far-right party won the largest share of +seats, entered government for the first time in July 2024, and the political +center responded by adopting more right-wing positions. Centrist parties, +according to this story, moved right to accommodate the new political reality. + +The data tells a different story. + +Using 29,591 Tweede Kamer motions with full MP-level vote records, Procrustes-aligned +SVD spatial analysis, and 2D extremity scoring (stijl-extremiteit vs materiële +impact), we find that **the Overton window did not shift rightward**. What changed +was the behavior of right-wing parties: they filed more motions, with milder +content, framed in centrist-friendly language. Centrist voting support surged +from 0.251 to 0.507 (Cohen's d = +0.65), but centrists did not become more +right-wing — they stayed ideologically left while voting more permissively on +proposals that had become less materially consequential. + +This article presents the evidence across three indicators — centrist voting +support, SVD spatial divergence, and 2D extremity decomposition — and examines +the mechanisms through which right-wing motions gained centrist support. + +## About Stemwijzer + +Stemwijzer is a data-driven political compass built from real parliamentary voting +records. It analyzes 29,591 motions from the Tweede Kamer (2016–2026), each with +per-MP vote records, to compute latent political dimensions using Singular Value +Decomposition (SVD). Users vote on real motions and find which MPs match their +positions — not based on party manifestos or campaign promises, but on how +representatives actually voted. + +The platform tracks party positions across 11 annual windows using +Procrustes-aligned SVD, allowing year-over-year comparison of spatial drift. +Every motion has been scored on two independent dimensions of extremity: +**stijl-extremiteit** (stylistic rhetoric, 1–5) and **materiële impact** +(material policy consequence, 1–5), manually validated with 75% auditor agreement. + +The Overton analysis presented here builds on this infrastructure. The same +SVD compass, extremity scores, and vote-level data that power the Stemwijzer +Explorer dashboard drive these findings. + +## Methodology + +**Right-wing motion classification.** We identify right-wing motions using a +hybrid keyword + voting-pattern classifier. A seed set of right-wing keywords +(vuurwerkverbod, stikstof, nareis, etc.) is expanded through an iterative +keyword-vote loop — motions whose voting pattern correlates with right-wing +party support are flagged, their distinctive terms extracted, and the keyword +set refined. The final classifier identifies 3,030 motions as right-wing across +2016–2026, with full voting records for centrist support computation. + +**2D extremity scoring.** Every motion in the database (29,591) has been scored +by an LLM on two dimensions: *stijl-extremiteit* (stylistic extremity: +inflammatory language, rhetorical framing) and *materiële impact* (material +impact: rights restriction, institutional change, resource reallocation), each +on a 1–5 scale. Manual audit of 117 stratified motions achieved 75% agreement. +The two dimensions are only moderately correlated (Pearson r = 0.43 for all +motions, r = 0.47 for right-wing), confirming they capture distinct +phenomena. + +**Strict centrist definition.** We define the centrist bloc narrowly as four +parties — D66, CDA, ChristenUnie, NSC — excluding VVD and BBB, which lean +center-right and would inflate centrist support mechanically. A strict +opposition-only filter further controls for coalition effects by excluding +motions whose lead submitter belongs to the governing coalition. + +**SVD alignment.** Party positions are computed via SVD on annual voting +matrices and aligned using chained Procrustes orthogonal rotation followed by +global PCA, placing all annual party positions in a common 2D reference frame. +Centrist and right-wing centers of gravity are computed as the mean of +party-level axis scores within each bloc. + +```{python} +#| label: chart-1-yearly-cs +#| fig-cap: "Centrist Support for Right-Wing Motions Over Time (2016–2026)" +#| column: page + +yearly = con.execute(""" + SELECT + year, + AVG(centrist_support_strict) AS mean_cs, + STDDEV(centrist_support_strict) AS std_cs, + COUNT(*) AS n + FROM right_wing_motions + WHERE classified = TRUE + GROUP BY year ORDER BY year +""").fetchdf() + +fig1 = go.Figure() + +fig1.add_trace(go.Scatter( + x=yearly["year"], y=yearly["mean_cs"], + mode="lines+markers", name="All right-wing", + line=dict(color="#002366", width=3), + marker=dict(size=8), + error_y=dict( + type="data", + array=1.96 * yearly["std_cs"] / np.sqrt(yearly["n"]), + visible=True, thickness=0.8, width=2 + ) +)) + +pre = yearly[yearly["year"] < BREAK_YEAR] +post = yearly[yearly["year"] >= BREAK_YEAR] + +fig1.add_hline( + y=pre["mean_cs"].mean(), + line_dash="dot", line_color="#90CAF9", + annotation_text=f"Pre-2024 mean ({pre['mean_cs'].mean():.3f})" +) +fig1.add_hline( + y=post["mean_cs"].mean(), + line_dash="dot", line_color="#1E88E5", + annotation_text=f"Post-2024 mean ({post['mean_cs'].mean():.3f})" +) + +fig1.add_vline( + x=BREAK_YEAR - 0.5, line_dash="dot", line_color="black", opacity=0.5 +) + +fig1.update_layout( + title="Centrist Support (Strict) for Right-Wing Motions", + xaxis=dict(title="Year", dtick=1), + yaxis=dict(title="Centrist Support (fraction of parties)", range=[0, 1.1]), + legend=dict(yanchor="top", y=0.99, xanchor="left", x=0.01), + template="plotly_white", height=450, +) +fig1.show() +``` + +## Indicator 1: Centrist Voting Support + +The cleanest signal is in how centrist parties voted on right-wing motions. +Average support rose from 0.251 pre-2024 to 0.507 post-2024 — a Cohen's d of ++0.65, a medium-to-large effect. The breakpoint is unmistakably 2024. + +This is not a coalition artifact. After the Schoof cabinet formed in July 2024, +PVV entered government, which could mechanically inflate support for its own +motions. When we restrict analysis to opposition-only right-wing motions (lead +submitter outside the governing coalition), the effect is larger: d = +0.85, +with support jumping from 0.270 to 0.543. Centrist parties are genuinely more +willing to support right-wing motions than they were before 2024, even when +those motions come from opposition right-wing parties. + +The gradient across extremity levels persists: centrists still differentiate by +how radical a motion is, but at a consistently higher baseline. High-extremity +motions gained proportionally more support than mild motions, consistent with +genuine tolerance expansion rather than compositional shift. + +**Pass rate is useless as an indicator.** Dutch parliament passes 96%+ of motions +in both periods. With near-zero variance, pass rate cannot register a shift of +any magnitude. Centrist support among MPs is the meaningful behavioral measure. + +```{python} +#| label: chart-2-gravity +#| fig-cap: "Gravity-Controlled Centrist Support by Material Impact Level, Pre vs Post 2024" +#| column: page + +gravity = con.execute(""" + SELECT + CASE WHEN r.year < 2024 THEN 'pre-2024' ELSE 'post-2024' END AS period, + e.materiele_impact AS m_level, + AVG(r.centrist_support_strict) AS cs, + COUNT(*) AS n + FROM right_wing_motions r + JOIN extremity_scores_all e ON r.motion_id = e.motion_id + WHERE r.classified = TRUE AND e.materiele_impact IS NOT NULL + GROUP BY period, m_level ORDER BY period, m_level +""").fetchdf() + +levels = sorted(gravity["m_level"].unique()) +pre_vals = gravity[gravity["period"] == "pre-2024"].set_index("m_level") +post_vals = gravity[gravity["period"] == "post-2024"].set_index("m_level") + +fig2 = go.Figure() + +fig2.add_trace(go.Bar( + name="Pre-2024", + x=[f"M={l}" for l in levels], + y=[pre_vals.loc[l, "cs"] if l in pre_vals.index else 0 for l in levels], + marker_color="#90CAF9", + text=[f"N={int(pre_vals.loc[l, 'n'])}" if l in pre_vals.index else "" for l in levels], + textposition="outside", + offset=0, +)) + +fig2.add_trace(go.Bar( + name="Post-2024", + x=[f"M={l}" for l in levels], + y=[post_vals.loc[l, "cs"] if l in post_vals.index else 0 for l in levels], + marker_color="#1E88E5", + text=[f"N={int(post_vals.loc[l, 'n'])}" if l in post_vals.index else "" for l in levels], + textposition="outside", + offset=0.3, +)) + +fig2.update_layout( + title="Gravity-Controlled Centrist Support by Material Impact", + xaxis=dict(title="Material Impact Level"), + yaxis=dict(title="Centrist Support", range=[0, 1.1]), + barmode="group", + template="plotly_white", height=450, + legend=dict(yanchor="top", y=0.99, xanchor="left", x=0.01), +) +fig2.show() +``` + +The gravity-controlled chart reveals a critical pattern: the centrist support +shift is real at **every** material impact level. From M=1 (mild procedural +adjustments, +0.292) to M=5 (systemic overhaul, +0.122), centrist support rose +across the board. The largest absolute gains came from the middle range (M=2: ++0.205, M=3: +0.219, M=4: +0.267), where most right-wing motions cluster. + +Comparing right-wing motions against all other motions confirms the shift is +specific: right-wing centrist support surged by +0.236, while non-right-wing +motions remained essentially flat (−0.006). This is a right-wing-specific +phenomenon, not a general parliamentary trend. + +## Indicator 2: Spatial Divergence + +If centrists are voting more with right-wing motions, one might expect +ideological convergence — centrist parties drifting rightward on the SVD +compass. Procrustes-aligned SVD analysis shows the opposite. + +```{python} +#| label: chart-3-svd +#| fig-cap: "SVD Trajectories: Centrist vs Right-Wing Centers of Gravity (2016–2026)" +#| column: page + +svd = con.execute(""" + SELECT * FROM overton_svd_center ORDER BY window_id +""").fetchdf() + +fig3 = go.Figure() + +fig3.add_trace(go.Scatter( + x=svd["centrist_mean_axis1"], y=svd["centrist_mean_axis2"], + mode="lines+markers+text", name="Centrist center", + line=dict(color="#00A36C", width=2), + marker=dict(size=8, symbol="circle"), + text=svd["window_id"], textposition="top center", +)) + +fig3.add_trace(go.Scatter( + x=svd["right_mean_axis1"], y=svd["right_mean_axis2"], + mode="lines+markers+text", name="Right-wing center", + line=dict(color="#002366", width=2), + marker=dict(size=8, symbol="square"), + text=svd["window_id"], textposition="bottom center", +)) + +fig3.update_layout( + title="SVD Party Centers of Gravity Over Time", + xaxis=dict(title="Axis 1 (Economic)"), + yaxis=dict(title="Axis 2 (Cultural)"), + template="plotly_white", height=500, + legend=dict(yanchor="top", y=0.99, xanchor="right", x=0.99), + hovermode="closest", +) +fig3.show() +``` + +Between the first and last annual windows: + +- **Centrists moved left on both axes:** −0.223 on the economic axis (more + welfare-oriented) and +0.081 on the cultural axis (more kosmopolitisch). +- **Right-wing parties moved further right culturally:** −0.065 on the cultural + axis (more nationalist). +- **The cultural distance between centrists and right-wing parties widened** + from 0.282 to 0.428 (+0.146). + +This is spatial divergence, not convergence. Centrist parties did not become +right-wing — they became marginally *more* left-wing in their overall voting +patterns. The centrist center of gravity moved toward welfare and cosmopolitanism, +while right-wing parties moved further into the nationalist corner. + +**Why this makes sense with the voting data:** The SVD captures the *full* +voting landscape — including all motions, not just the ones centrists supported. +Right-wing parties continued filing high-impact motions that centrists opposed, +while simultaneously filing a much larger volume of milder motions centrists +supported. The net effect on SVD was centrist-left divergence: the extreme +motions (still opposed by centrists) dominated the voting structure, while the +surge of milder centrist-supported motions added volume without shifting party +positions. This is "acceptance without conversion" — centrists vote more with +right-wing motions while moving further from them ideologically. + +## Indicator 3: Content Moderation + +The original single-dimensional extremity score showed no increase post-2024 +(d = −0.09, from 2.21 to 2.15). If the Overton window shifted, why didn't +right-wing motions become more radical? + +The answer lies in what the single score measured. Two-dimensional rescoring +of all 29,591 motions reveals that stylistic extremity and material impact are +only moderately correlated (r = 0.43). When tracked separately over time, they +tell different stories. + +```{python} +#| label: chart-4-2d-extremity +#| fig-cap: "2D Extremity Over Time: Stijl vs Materieel (Right-Wing Motions, 2019–2026)" +#| column: page + +extremity_2d = con.execute(""" + SELECT + r.year, + AVG(e.stijl_extremiteit) AS mean_stijl, + AVG(e.materiele_impact) AS mean_mat, + COUNT(*) AS n + FROM right_wing_motions r + JOIN extremity_scores_all e ON r.motion_id = e.motion_id + WHERE r.classified = TRUE AND r.year >= 2019 + GROUP BY r.year ORDER BY r.year +""").fetchdf() + +all_stijl, all_mat = con.execute(""" + SELECT AVG(stijl_extremiteit), AVG(materiele_impact) + FROM extremity_scores_all +""").fetchone() + +fig4 = make_subplots( + rows=1, cols=2, + subplot_titles=("Stylistic Extremity (Stijl)", "Material Impact (Materieel)"), + shared_yaxes=False, +) + +fig4.add_trace( + go.Scatter( + x=extremity_2d["year"], y=extremity_2d["mean_stijl"], + mode="lines+markers", name="Right-wing", + line=dict(color="#6A1B9A", width=3), + marker=dict(size=8), + ), + row=1, col=1, +) + +fig4.add_hline( + y=all_stijl, line_dash="dot", line_color="#9E9E9E", + annotation_text=f"All motions ({all_stijl:.2f})", + row=1, col=1, +) + +fig4.add_trace( + go.Scatter( + x=extremity_2d["year"], y=extremity_2d["mean_mat"], + mode="lines+markers", name="Right-wing", + line=dict(color="#E53935", width=3), + marker=dict(size=8), + ), + row=1, col=2, +) + +fig4.add_hline( + y=all_mat, line_dash="dot", line_color="#9E9E9E", + annotation_text=f"All motions ({all_mat:.2f})", + row=1, col=2, +) + +fig4.update_layout( + title="2D Extremity Decomposition: Stijl vs Materieel", + template="plotly_white", height=400, + showlegend=False, +) +fig4.update_xaxes(title="Year", dtick=1) +fig4.update_yaxes(title="Score (1–5)", range=[0.5, 4]) +fig4.show() +``` + +| Dimension | Pre-2024 Mean | Post-2024 Mean | Δ | +|-----------|--------------|---------------|-----| +| Stylistic extremity | 1.718 | 1.815 | +0.097 | +| Material impact | 2.530 | 2.384 | −0.146 | +| Gap (M−S) | 0.813 | 0.570 | −0.243 | + +Material impact *decreased* (−0.146) while stylistic extremity *increased* +(+0.097). A Wilcoxon signed-rank test comparing yearly mean stylistic vs yearly +mean material scores confirms the dimensions systematically differ (W = 0.0, +n = 10 yearly pairs, p = 0.002). The gap between the two dimensions narrowed +from 0.813 to 0.570 — right-wing motions became both less rhetorically hostile +AND less substantively impactful. + +Compared to all motions, right-wing motions score higher on both dimensions: +stijl +0.47, materieel +0.54. The masking rate — restrained language paired +with high material impact (S ≤ 2, M ≥ 3) — is 36.1% for right-wing motions +vs 24.0% for all motions. Right-wing proposals disproportionately use +procedural language to advance consequential policy. + +## Mechanisms of Influence + +If centrists didn't become right-wing, *how* did right-wing motions gain their +support? A systematic classification of 150 post-2024 motions (stratified by +centrist support level) identifies the dominant pathways. + +```{python} +#| label: chart-5-mechanisms +#| fig-cap: "Mechanism Distribution: High-Support vs Low-Support Post-2024 Motions" +#| column: page + +mechanisms = [ + "Procedureel/technisch", + "Consensus framing", + "Gerichte restrictie", + "Institutioneel/rechtsstatelijk", + "Symbolisch/declaratoir", + "Welzijn/dienstverlening", + "Lokaal/regionaal", + "Coalitie-afstemming", + "Crisisrespons", + "Systeemontmanteling", +] + +high_support = [24, 18, 13, 7, 4, 3, 3, 2, 1, 0] +low_support = [9, 6, 21, 19, 5, 1, 1, 0, 0, 13] + +fig5 = go.Figure() + +fig5.add_trace(go.Bar( + name="High-support (CS > 0.5)", + x=mechanisms, y=high_support, + marker_color="#1E88E5", +)) + +fig5.add_trace(go.Bar( + name="Low-support (CS ≤ 0.5)", + x=mechanisms, y=low_support, + marker_color="#90CAF9", +)) + +fig5.update_layout( + title="Mechanism Classification: High-Support vs Low-Support Post-2024", + xaxis=dict(title="Mechanism", tickangle=45), + yaxis=dict(title="Count"), + barmode="group", + template="plotly_white", height=450, + legend=dict(yanchor="top", y=0.99, xanchor="right", x=0.99), +) +fig5.show() +``` + +The contrast between high- and low-support post-2024 motions is sharp. + +**High-support motions (CS > 0.5)** are dominated by procedural/technical +framing (32%), consensus framing appealing to shared values (24%), and targeted +restriction rather than blanket bans (17%). Institutional challenges and system +dismantling are notably absent. + +**Low-support motions (CS ≤ 0.5)** are dominated by targeted restriction (28%), +institutional challenges (25%), and system dismantling (17%). Zero system +dismantling motions achieved high centrist support. + +Consensus framing is significantly more common in high-support motions (24%) +than low-support (8%): χ²(1) = 6.00, p = 0.014. The hypothesis that consensus +framing drives centrist support is confirmed. + +**Party-level analysis** reveals the shift is not uniform. JA21 is the primary +driver, with a +0.203 CS shift and the only volume + support gains combination. +PVV entered government and filed fewer, milder motions. FVD remains structurally +shunned — its motions consistently fail to gain centrist support regardless of +content. + +## Temporal Dynamics + +Quarterly analysis across 33 quarters (2016-Q2 through 2026-Q1) replaces the +binary pre/post-2024 comparison with a continuous trajectory that reveals the +exact timing, shape, and sustainability of the shift. + +```{python} +#| label: chart-6-quarterly +#| fig-cap: "Quarterly Centrist Support Trajectory (2016–2026)" +#| column: page + +quarterly = con.execute(""" + SELECT + EXTRACT(YEAR FROM m.date) AS y, + CEIL(EXTRACT(MONTH FROM m.date) / 3.0) AS q, + AVG(r.centrist_support_strict) AS cs, + COUNT(*) AS n, + STDDEV(r.centrist_support_strict) AS std_cs + FROM right_wing_motions r + JOIN motions m ON r.motion_id = m.id + WHERE r.classified = TRUE AND m.date IS NOT NULL + GROUP BY y, q ORDER BY y, q +""").fetchdf() + +quarterly["label"] = quarterly["y"].astype(int).astype(str) + "-Q" + quarterly["q"].astype(int).astype(str) + +inflection_idx = quarterly[(quarterly["y"].astype(int) == 2024) & (quarterly["q"].astype(int) == 1)].index +peak_idx = quarterly[(quarterly["y"].astype(int) == 2024) & (quarterly["q"].astype(int) == 4)].index +latest_idx = quarterly[(quarterly["y"].astype(int) == 2026) & (quarterly["q"].astype(int) == 1)].index + +fig6 = go.Figure() + +fig6.add_trace(go.Scatter( + x=quarterly["label"], y=quarterly["cs"], + mode="lines+markers", + line=dict(color="#002366", width=3), + marker=dict(size=6), + error_y=dict( + type="data", + array=1.96 * quarterly["std_cs"] / np.sqrt(quarterly["n"]), + visible=True, thickness=0.6, width=1.5, + ), + name="Centrist Support", +)) + +for idx in [inflection_idx, peak_idx, latest_idx]: + if len(idx) > 0: + i = idx[0] + fig6.add_annotation( + x=quarterly.loc[i, "label"], y=quarterly.loc[i, "cs"], + text=f'{quarterly.loc[i, "cs"]:.3f}', + showarrow=True, arrowhead=1, ax=0, ay=-30, + ) + +fig6.add_shape( + type="line", x0="2024-Q1", x1="2024-Q1", y0=0, y1=1, + line=dict(dash="dot", color="red", width=1.5), +) +fig6.add_annotation( + x="2024-Q1", y=0.95, + text="PVV election (Nov 2023)", + showarrow=False, textangle=-90, + font=dict(color="red", size=10), +) + +fig6.add_shape( + type="line", x0="2024-Q3", x1="2024-Q3", y0=0, y1=1, + line=dict(dash="dot", color="orange", width=1.5), +) +fig6.add_annotation( + x="2024-Q3", y=0.88, + text="Schoof cabinet (Jul 2024)", + showarrow=False, textangle=-90, + font=dict(color="orange", size=10), +) + +fig6.update_layout( + title="Quarterly Centrist Support Trajectory", + xaxis=dict( + title="Quarter", + tickangle=45, + tickmode="array", + tickvals=quarterly["label"][::4], + ), + yaxis=dict(title="Centrist Support", range=[0, 1.0]), + template="plotly_white", height=450, +) +fig6.show() +``` + +**Timing.** The inflection point is 2024-Q1, the quarter immediately following +the PVV's November 2023 election victory. Centrist support jumped from 0.321 +(2023-Q4) to 0.501 (2024-Q1) — a single-quarter increase of +0.180, roughly +twice the average quarterly change. + +**Shape.** Centrist support rose sharply through 2024-Q4, reaching an all-time +peak of 0.648 in the first full quarter of the Schoof cabinet. From that peak, +it declined steadily: 0.598, 0.503, 0.437, 0.450, and 0.334 in 2026-Q1 — +below the 0.4 inflection threshold and approaching pre-shift levels. + +**Causal mechanism.** The shift began before the Schoof cabinet formed (July +2024), appearing immediately after the PVV election. This rules out coalition +dynamics as the primary driver. The most parsimonious explanation: centrist +parties perceived the PVV's electoral success as a mandate for right-wing policy +and adjusted their voting behavior accordingly. + +**Sustainability.** The 2026-Q1 reversion to 0.334 raises a critical question: +is the centrist support surge a temporary electoral-cycle effect rather than a +permanent Overton window shift? The trajectory resembles an electoral response +function — a rapid jump after the election, a peak during the cabinet honeymoon, +and a gradual decline. The "new normal" may be closer to 0.33 than to 0.65. + +| Hypothesis | Evidence | Verdict | +|------------|----------|---------| +| Electoral shock | Jump immediately followed PVV victory (Nov 2023) | **Supported** | +| Coalition dynamics | Shift began 3 quarters before cabinet formed | **Refuted** | +| Gradual learning | Jump was 1.9× average quarterly — discrete, not incremental | **Refuted** | +| European contagion | No Dutch response during 2022–2023 European shift | **Refuted** | + +## Verdict: Acceptance Through Moderation + +**The Overton window did not shift right. Right-wing parties moderated toward +it. That moderation effect may be temporary.** + +1. **Volume surged, impact declined.** Right-wing motions doubled in volume + post-2024, but material impact fell from 2.78 to 2.43 (Cohen's d = −0.36). + The M ≥ 4 share dropped from 23.7% to 11.3% and continued falling to 2.7% + by 2026. + +2. **Centrists did not become more tolerant.** The extremity-stratified + gradient persists — centrists still differentiate between mild and extreme + motions. The across-the-board baseline shift reflects that content within + each bucket became milder, not that centrists lowered their standards. + +3. **The mechanism is strategic moderation, systematically confirmed.** Zero + system-dismantling proposals achieved high centrist support post-2024. The + dominant pathways — procedural/technical (32%), consensus framing (24%), + and targeted restriction (17%) — show right-wing parties learned which + frames work. + +4. **SVD divergence confirms this.** Centrists moved left spatially as the + extreme tail polarized even as cooperation grew on the moderate mass. + +5. **The shift is electorally driven and possibly temporary.** Centrist support + surged immediately after the PVV election, peaked at 0.648 in 2024-Q4, and + has since reverted to 0.334 in 2026-Q1 — approaching pre-shift levels. + +**With one exception: migration.** The asylum/migration domain shows a pattern +distinct from all others. Material impact barely declined (−0.13), yet centrist +support more than doubled (0.153 → 0.369). Centrists went from zero support for +M = 5 migration motions to nearly 20%. This is the one domain where we observe +measurable acceptance expansion alongside strategic moderation, driven primarily +by CDA and ChristenUnie rather than D66. + +### Limitations + +- **Small-N time series:** 8 pre-2024 annual windows and 3 post-2024 + (2026 is partial). Effect sizes are descriptive Cohen's d, not inferred from + a time-series model. +- **Coalition coding:** 2024 is ambiguous (Rutte IV until July, Schoof + thereafter). Opposition-only analysis and temporal timing mitigate this. +- **Mechanism classification:** Based on 150 post-2024 motions, single-classifier + assignment. Inter-rater agreement is moderate (κ = 0.41). +- **Causal direction:** The timing strongly supports an electoral explanation, + but this remains correlational. +- **Success ceiling:** 96%+ pass rate makes pass rate an insensitive dependent + variable. + +### Explore the Data + +This article is one surface of a three-tier analysis: + +1. **Narrative spine** — you're reading it. The story, with key evidence. +2. **Technical appendices** — detailed markdown reports in `reports/overton_window/` + cover every methodological decision, robustness check, and sensitivity + analysis. +3. **Live exploration** — explore the Stemwijzer Explorer: + - **Kompas tab** — party positions on the SVD axes + - **Trajectories tab** — how parties drifted over time + - **Overton tab** — centrist support trends and right-wing motion browser + +**Visit the Explorer** at `localhost:8501` to interact with the compass, plot +your position, and verify these findings against the underlying vote data. + +```{python} +#| label: close-connection +#| include: false + +con.close() +``` diff --git a/reports/overton_window/overton_window_synthesis.md b/reports/overton_window/overton_window_synthesis.md index aa606fd..95c715e 100644 --- a/reports/overton_window/overton_window_synthesis.md +++ b/reports/overton_window/overton_window_synthesis.md @@ -5,7 +5,7 @@ #HL 2#DA3#372| #HL 3#C20#A0D|**Date:** 2026-05-26 #HL 4#29E#3A6|**Analysis period:** 2016–2026 -#HL 5#3E8#AA1|**Data:** 2,869 classified right-wing motions with 2D extremity scores (96% of all 2,986), Procrustes-aligned SVD party positions across 10 annual windows, MP-level vote records for centrist parties (D66, CDA, ChristenUnie, NSC) and left-wing parties (SP, GroenLinks-PvdA, PvdD, Volt, DENK), quarterly centrist support trajectories (33 quarters), 150-motion systematic mechanism classification +#HL 5#3E8#AA1|**Data:** 29,591 motions with 2D extremity scores (`extremity_scores_all`), including 3,089 right-wing motions with dedicated 2D scores (`extremity_scores_2d`), Procrustes-aligned SVD party positions across 10 annual windows, MP-level vote records for centrist parties (D66, CDA, ChristenUnie, NSC) and left-wing parties (SP, GroenLinks-PvdA, PvdD, Volt, DENK), quarterly centrist support trajectories (33 quarters), 150-motion systematic mechanism classification #HL 6#DA3#880| #HL 7#58B#25E|--- #HL 8#DA3#1F8| @@ -119,11 +119,13 @@ #HL 116#DA3#992| #HL 117#4D0#A81|Two-dimensional rescoring of 117 motions (stratified across extremity buckets) confirmed this. Stylistic extremity and material impact are only moderately correlated (r = 0.45), explaining just 20% of each other's variance. Material impact averages 2.86, compared to 2.01 for stylistic extremity — a consistent gap of 0.85 points. **36.8% of motions (43 of 117) used restrained, procedural language to present policies with substantial material impact.** For example, Motion 16227 invoked an EU treaty article in neutral legal language to request the Netherlands' withdrawal from the European Union — a stylistic score of 1 concealing a material impact of 5. #HL 118#DA3#82E| -#HL 119#BCE#615|The expanded dataset (2,850 classified motions) broadly confirms the sample findings. The overall Pearson r between stylistic and material extremity is 0.47 (95% CI: approximately ±0.03), with material impact averaging 0.83 points above stylistic. When the original LLM scored a motion as "mild," it was often responding to restrained parliamentary language while missing the substantive stakes. +#HL 119#BCE#615|The expanded dataset (29,591 motions across all motions in `extremity_scores_all`) broadly confirms the sample findings. The all-motion Pearson r between stylistic and material extremity is 0.43, with material impact averaging 0.76 points above stylistic. Within the right-wing subset (3,089 motions), the correlation is slightly tighter at r=0.47 — but both are in the moderate range, confirming the two dimensions are separable across all motion types. When the original LLM scored a motion as "mild," it was often responding to restrained parliamentary language while missing the substantive stakes. + +Right-wing motions score significantly higher on both dimensions than the overall motion population: mean stylistic extremity 1.83 vs 1.36 (Δ=+0.47), mean material impact 2.66 vs 2.12 (Δ=+0.54). The masking rate — restrained language paired with high material impact — is 36.1% for right-wing motions vs 24.0% for all motions, confirming that the procedural-language-for-consequential-policy pattern is amplified in right-wing proposals. #HL 120#DA3#AB6| #HL 121#2AD#6F4|### 2D Extremity Trajectories #HL 122#DA3#C56| -#HL 123#0C3#30B|The single-dimension trend conceals diverging trajectories when stylistic and material extremity are tracked separately over time (2016–2026, n=2,869 scored motions). The two dimensions are significantly decoupled: overall correlation r=0.47 (p<0.001), leaving 78% of variance unexplained. +#HL 123#0C3#30B|The single-dimension trend conceals diverging trajectories when stylistic and material extremity are tracked separately over time (2016–2026, n=2,869 scored motions). The two dimensions are significantly decoupled: right-wing correlation r=0.47 (p<0.001), all-motion r=0.43, leaving 78–82% of variance unexplained. #HL 124#DA3#7BC| #HL 125#4F2#F27|| Dimension | Pre-2024 Mean | Post-2024 Mean | Δ | #HL 126#0BB#766||-----------|--------------|---------------|-----| @@ -283,3 +285,7 @@ #HL 280#566#8B5|- **Mechanism classification:** Based on 200 motions (50 pre, 150 post), single-classifier assignment, and a binary support threshold (CS > 0.5). No inter-rater validation was performed. Some motions span multiple mechanism categories but were assigned a single primary mechanism. #HL 281#748#D23|- **Causal direction:** This analysis establishes a structural break in centrist voting behavior and its temporal alignment with political events. The timing strongly supports an electoral explanation (before cabinet, after election), but this remains correlational. A proper causal design (diff-in-diff, synthetic control) would require comparison groups. #HL 282#2FA#A77|- **Success ceiling:** The 96%+ pass rate makes pass rate an insensitive dependent variable for measuring centrist influence on legislative outcomes. The success correlation findings should be interpreted as describing a real but practically constrained relationship. + +### Visualization + +**Primary interactive visualization deliverable:** `reports/overton_window/overton_report.html` — an HTML dashboard with all three indicators (centrist support, SVD spatial drift, 2D extremity trajectories) in linked views, including the all-motion vs right-wing comparison. diff --git a/reports/overton_window/party_differentiation.md b/reports/overton_window/party_differentiation.md index 5c47316..0cb04ce 100644 --- a/reports/overton_window/party_differentiation.md +++ b/reports/overton_window/party_differentiation.md @@ -1,12 +1,14 @@ # Right-Wing Party Differentiation +> **Part of the Overton Window Analysis.** See the [synthesis report](overton_window_synthesis.md) for the integrated narrative, or the [interactive article](overton_window.qmd) for the full story with charts. + **Goal:** Break down right-wing motion metrics by party (PVV, FVD, JA21, SGP) to identify which party drives the moderation effect. **Analysis period:** 2016–2026 **Right-wing parties:** FVD, JA21, PVV, SGP -**Data:** 962 right-wing submitter motions with 2D extremity scores -(from 2,850 classified right-wing motions total; 1,888 could not be parsed/party-matched). +**Data:** 1,018 right-wing submitter motions with 2D extremity scores +(from 3,030 classified right-wing motions total; 2,012 could not be parsed/party-matched). --- @@ -17,14 +19,14 @@ to identify which party drives the moderation effect. | 2016 | 0 | 0 | 0 | 0 | 0 | | 2017 | 0 | 0 | 0 | 0 | 0 | | 2018 | 0 | 0 | 0 | 0 | 0 | -| 2019 | 9 | 0 | 41 | 20 | 70 | -| 2020 | 44 | 0 | 87 | 31 | 162 | -| 2021 | 23 | 17 | 70 | 35 | 145 | -| 2022 | 11 | 20 | 58 | 31 | 120 | -| 2023 | 13 | 20 | 52 | 27 | 112 | -| 2024 | 6 | 52 | 34 | 29 | 121 | -| 2025 | 21 | 54 | 54 | 21 | 150 | -| 2026 | 11 | 33 | 35 | 3 | 82 | +| 2019 | 9 | 0 | 42 | 20 | 71 | +| 2020 | 45 | 0 | 97 | 31 | 173 | +| 2021 | 25 | 18 | 73 | 36 | 152 | +| 2022 | 17 | 22 | 65 | 32 | 136 | +| 2023 | 13 | 20 | 54 | 28 | 115 | +| 2024 | 7 | 55 | 35 | 29 | 126 | +| 2025 | 23 | 58 | 58 | 22 | 161 | +| 2026 | 11 | 34 | 36 | 3 | 84 | --- @@ -35,14 +37,14 @@ to identify which party drives the moderation effect. | 2016 | N/A | N/A | N/A | N/A | | 2017 | N/A | N/A | N/A | N/A | | 2018 | N/A | N/A | N/A | N/A | -| 2019 | 0.000 | N/A | 0.074 | 0.350 | -| 2020 | 0.057 | N/A | 0.052 | 0.387 | -| 2021 | 0.000 | 0.088 | 0.014 | 0.286 | -| 2022 | 0.000 | 0.050 | 0.043 | 0.242 | -| 2023 | 0.000 | 0.075 | 0.067 | 0.407 | -| 2024 | 0.056 | 0.212 | 0.314 | 0.506 | -| 2025 | 0.095 | 0.315 | 0.139 | 0.603 | -| 2026 | 0.000 | 0.300 | 0.086 | 0.167 | +| 2019 | 0.000 | N/A | 0.096 | 0.350 | +| 2020 | 0.056 | N/A | 0.046 | 0.387 | +| 2021 | 0.000 | 0.083 | 0.014 | 0.278 | +| 2022 | 0.000 | 0.045 | 0.038 | 0.266 | +| 2023 | 0.000 | 0.075 | 0.065 | 0.393 | +| 2024 | 0.048 | 0.200 | 0.333 | 0.506 | +| 2025 | 0.087 | 0.328 | 0.129 | 0.591 | +| 2026 | 0.000 | 0.321 | 0.083 | 0.167 | --- @@ -53,14 +55,14 @@ to identify which party drives the moderation effect. | 2016 | N/A | N/A | N/A | N/A | | 2017 | N/A | N/A | N/A | N/A | | 2018 | N/A | N/A | N/A | N/A | -| 2019 | 3.56 | N/A | 3.34 | 2.65 | -| 2020 | 3.18 | N/A | 3.30 | 2.84 | -| 2021 | 2.96 | 3.41 | 3.23 | 2.91 | -| 2022 | 2.45 | 3.05 | 2.67 | 2.26 | -| 2023 | 2.92 | 3.85 | 3.25 | 2.74 | -| 2024 | 3.50 | 3.13 | 2.50 | 2.52 | -| 2025 | 3.00 | 2.44 | 2.50 | 2.10 | -| 2026 | 1.91 | 2.36 | 2.54 | 2.00 | +| 2019 | 3.56 | N/A | 3.33 | 2.65 | +| 2020 | 3.16 | N/A | 3.37 | 2.84 | +| 2021 | 3.04 | 3.44 | 3.29 | 2.86 | +| 2022 | 2.53 | 3.00 | 2.72 | 2.25 | +| 2023 | 2.92 | 3.85 | 3.30 | 2.71 | +| 2024 | 3.71 | 3.15 | 2.49 | 2.52 | +| 2025 | 2.96 | 2.45 | 2.60 | 2.09 | +| 2026 | 1.91 | 2.38 | 2.58 | 2.00 | --- @@ -68,40 +70,40 @@ to identify which party drives the moderation effect. | Party | N Pre | N Post | CS Pre | CS Post | Delta CS | Mat. Pre | Mat. Post | Delta Mat. | Vol. Delta | |-------|-------|--------|--------|---------|----------|----------|-----------|------------|------------| -| FVD | 100 | 38 | 0.025 | 0.061 | +0.036 | 3.05 | 2.76 | -0.29 | -62 | -| JA21 | 57 | 139 | 0.070 | 0.273 | +0.203 | 3.44 | 2.68 | -0.76 | +82 | -| PVV | 308 | 123 | 0.047 | 0.172 | +0.125 | 3.16 | 2.51 | -0.65 | -185 | -| SGP | 144 | 53 | 0.330 | 0.525 | +0.195 | 2.69 | 2.32 | -0.37 | -91 | +| FVD | 109 | 41 | 0.023 | 0.057 | +0.034 | 3.04 | 2.80 | -0.23 | -68 | +| JA21 | 60 | 147 | 0.067 | 0.278 | +0.212 | 3.42 | 2.69 | -0.72 | +87 | +| PVV | 331 | 129 | 0.047 | 0.172 | +0.125 | 3.21 | 2.57 | -0.64 | -202 | +| SGP | 147 | 54 | 0.330 | 0.522 | +0.192 | 2.67 | 2.31 | -0.35 | -93 | --- ## 5. Key Findings **Centrist support shift (largest to smallest):** -- **JA21**: +0.203 -- **SGP**: +0.195 +- **JA21**: +0.212 +- **SGP**: +0.192 - **PVV**: +0.125 -- **FVD**: +0.036 +- **FVD**: +0.034 ### Volume -- **FVD**: 100 pre-2024 → 38 post-2024 (-62) -- **JA21**: 57 pre-2024 → 139 post-2024 (+82) -- **PVV**: 308 pre-2024 → 123 post-2024 (-185) -- **SGP**: 144 pre-2024 → 53 post-2024 (-91) +- **FVD**: 109 pre-2024 → 41 post-2024 (-68) +- **JA21**: 60 pre-2024 → 147 post-2024 (+87) +- **PVV**: 331 pre-2024 → 129 post-2024 (-202) +- **SGP**: 147 pre-2024 → 54 post-2024 (-93) ### Material Impact Shift -- **FVD**: 3.05 → 2.76 (-0.29) -- **JA21**: 3.44 → 2.68 (-0.76) -- **PVV**: 3.16 → 2.51 (-0.65) -- **SGP**: 2.69 → 2.32 (-0.37) +- **FVD**: 3.04 → 2.80 (-0.23) +- **JA21**: 3.42 → 2.69 (-0.72) +- **PVV**: 3.21 → 2.57 (-0.64) +- **SGP**: 2.67 → 2.31 (-0.35) --- ## 6. Parsing Notes -- Parsed and party-matched: 962 motions -- Right-wing submitter motions: 962 -- Unmatched/unparsed: 1,888 +- Parsed and party-matched: 1,018 motions +- Right-wing submitter motions: 1,018 +- Unmatched/unparsed: 2,012 - Submitter party is parsed from motion title prefixes (e.g. 'Motie van het lid Wilders ...'). - Multi-submitter motions use the first listed submitter. - Party names are normalized via `_PARTY_NORMALIZE` (e.g. Groep Markuszower → PVV). diff --git a/reports/overton_window/party_differentiation_figure.png b/reports/overton_window/party_differentiation_figure.png index 991ce5c..7b10462 100644 Binary files a/reports/overton_window/party_differentiation_figure.png and b/reports/overton_window/party_differentiation_figure.png differ diff --git a/reports/overton_window/predictive_model.md b/reports/overton_window/predictive_model.md index 37a228f..5985ba5 100644 --- a/reports/overton_window/predictive_model.md +++ b/reports/overton_window/predictive_model.md @@ -1,15 +1,17 @@ # Predictive Model: Centrist Support -**Generated:** 2026-05-31 19:36 +> **Part of the Overton Window Analysis.** See the [synthesis report](overton_window_synthesis.md) for the integrated narrative, or the [interactive article](overton_window.qmd) for the full story with charts. + +**Generated:** 2026-06-06 10:39 ## Data Summary -- Total classified right-wing motions with 2D extremity scores: **2850** -- Valid for modeling (right-wing submitter party + valid category): **914** -- High centrist support (>0.5) : 115 motions -- Low centrist support (<=0.5): 799 motions -- Class imbalance ratio: 6.9:1 (low:high) -- Features: 22 +- Total classified right-wing motions with 2D extremity scores: **3030** +- Valid for modeling (right-wing submitter party + valid category): **965** +- High centrist support (>0.5) : 120 motions +- Low centrist support (<=0.5): 845 motions +- Class imbalance ratio: 7.0:1 (low:high) +- Features: 10 ## Model Performance @@ -17,15 +19,15 @@ | Model | Accuracy | Precision | Recall | AUC-ROC | |-------|----------|-----------|--------|---------| -| Logistic Regression | 0.710 | 0.258 | 0.696 | 0.810 | -| Random Forest | 0.852 | 0.423 | 0.478 | 0.795 | +| Logistic Regression | 0.725 | 0.262 | 0.667 | 0.799 | +| Random Forest | 0.839 | 0.111 | 0.042 | 0.769 | ### 5-Fold Cross-Validation | Model | Mean Accuracy | Std Accuracy | Mean AUC-ROC | Std AUC-ROC | |-------|---------------|-------------|--------------|-------------| -| Logistic Regression | 0.718 | 0.032 | 0.815 | 0.036 | -| Random Forest | 0.862 | 0.016 | 0.835 | 0.048 | +| Logistic Regression | 0.730 | 0.021 | 0.828 | 0.039 | +| Random Forest | 0.854 | 0.023 | 0.831 | 0.035 | ## Feature Importance @@ -33,16 +35,16 @@ | Feature | Coefficient | Odds Ratio | |---------|-------------|------------| -| `cat_corona/pandemie` | -1.4680 | 0.2304 | -| `party_FVD` | -1.3282 | 0.2650 | -| `party_SGP` | 0.9877 | 2.6852 | -| `party_JA21` | 0.9264 | 2.5255 | -| `stijl_extremiteit` | -0.6859 | 0.5036 | -| `party_PVV` | -0.6394 | 0.5276 | -| `cat_onderwijs/cultuur` | 0.5472 | 1.7285 | -| `cat_zorg/gezondheid` | -0.4857 | 0.6153 | -| `materiele_impact` | -0.4741 | 0.6225 | -| `cat_overig` | 0.4658 | 1.5933 | +| `party_FVD` | -1.0534 | 0.3488 | +| `party_SGP` | 1.0354 | 2.8163 | +| `stijl_extremiteit` | -0.7955 | 0.4514 | +| `party_JA21` | 0.6673 | 1.9489 | +| `party_PVV` | -0.6524 | 0.5208 | +| `materiele_impact` | -0.5428 | 0.5811 | +| `year` | 0.4052 | 1.4996 | +| `is_opposition` | -0.3080 | 0.7349 | +| `text_length` | 0.1133 | 1.1200 | +| `cat_overig` | -0.0031 | 0.9969 | *Positive coefficient = higher feature value increases odds of high centrist support.* @@ -50,34 +52,34 @@ | Feature | Importance (Gini) | |---------|-------------------| -| `text_length` | 0.2137 | -| `year` | 0.1915 | -| `stijl_extremiteit` | 0.1410 | -| `materiele_impact` | 0.0946 | -| `party_SGP` | 0.0652 | -| `party_FVD` | 0.0489 | -| `party_PVV` | 0.0407 | -| `cat_veiligheid/justitie` | 0.0258 | -| `cat_defensie/buitenland` | 0.0246 | -| `party_JA21` | 0.0234 | +| `text_length` | 0.3287 | +| `year` | 0.2176 | +| `stijl_extremiteit` | 0.1893 | +| `materiele_impact` | 0.1147 | +| `party_SGP` | 0.0508 | +| `party_FVD` | 0.0360 | +| `party_PVV` | 0.0298 | +| `party_JA21` | 0.0200 | +| `is_opposition` | 0.0132 | +| `cat_overig` | 0.0000 | ## Interpretation ### Top 5 Most Important Features **Logistic Regression (coefficient magnitude):** -1. `cat_corona/pandemie` (coef=-1.4680, OR=0.2304) — decreases odds of high centrist support -2. `party_FVD` (coef=-1.3282, OR=0.2650) — decreases odds of high centrist support -3. `party_SGP` (coef=0.9877, OR=2.6852) — increases odds of high centrist support -4. `party_JA21` (coef=0.9264, OR=2.5255) — increases odds of high centrist support -5. `stijl_extremiteit` (coef=-0.6859, OR=0.5036) — decreases odds of high centrist support +1. `party_FVD` (coef=-1.0534, OR=0.3488) — decreases odds of high centrist support +2. `party_SGP` (coef=1.0354, OR=2.8163) — increases odds of high centrist support +3. `stijl_extremiteit` (coef=-0.7955, OR=0.4514) — decreases odds of high centrist support +4. `party_JA21` (coef=0.6673, OR=1.9489) — increases odds of high centrist support +5. `party_PVV` (coef=-0.6524, OR=0.5208) — decreases odds of high centrist support **Random Forest (Gini importance):** -1. `text_length` (importance=0.2137) -2. `year` (importance=0.1915) -3. `stijl_extremiteit` (importance=0.1410) -4. `materiele_impact` (importance=0.0946) -5. `party_SGP` (importance=0.0652) +1. `text_length` (importance=0.3287) +2. `year` (importance=0.2176) +3. `stijl_extremiteit` (importance=0.1893) +4. `materiele_impact` (importance=0.1147) +5. `party_SGP` (importance=0.0508) ### Which features best predict centrist support? @@ -95,6 +97,6 @@ different support patterns than coalition-submitted ones. ### Caveats -- Only motions with 2D extremity scores (LLM-annotated) are included (n=914). +- Only motions with 2D extremity scores (LLM-annotated) are included (n=965). - Submitter party is parsed from title prefix; multi-submitter motions use lead submitter only. - Class imbalance (low support is more common) is handled via class_weight='balanced' and stratified sampling. diff --git a/reports/overton_window/predictive_model_figure.png b/reports/overton_window/predictive_model_figure.png index be8562b..a8c1d2f 100644 Binary files a/reports/overton_window/predictive_model_figure.png and b/reports/overton_window/predictive_model_figure.png differ diff --git a/reports/overton_window/success_correlation.md b/reports/overton_window/success_correlation.md index 509f680..db71091 100644 --- a/reports/overton_window/success_correlation.md +++ b/reports/overton_window/success_correlation.md @@ -1,13 +1,15 @@ # Motion Success Correlation Analysis +> **Part of the Overton Window Analysis.** See the [synthesis report](overton_window_synthesis.md) for the integrated narrative, or the [interactive article](overton_window.qmd) for the full story with charts. + **Goal:** Test whether motions with high centrist support actually passed at higher rates, validating that centrist support translates to legislative success. **Analysis period:** 2016–2026 -**Total right-wing motions:** 2986 -**Motions with determinable outcome:** 2986 -**Motions passed:** 2894 (96.9%) -**Government motions:** 620 · **Opposition motions:** 1700 · **Unknown type:** 666 +**Total right-wing motions:** 3030 +**Motions with determinable outcome:** 3030 +**Motions passed:** 2938 (97.0%) +**Government motions:** 620 · **Opposition motions:** 1700 · **Unknown type:** 710 --- @@ -18,9 +20,9 @@ Quartile bins are: [0-0.25], (0.25-0.50], (0.50-0.75], (0.75-1.0]. | Stratum | Q1 [0.00–0.25] | Q2 (0.25–0.50] | Q3 (0.50–0.75] | Q4 (0.75–1.00] | N total | Trend χ² | p-value | |---------|--------------|--------------|--------------|--------------|---------|-----------|---------| -| all | 96.3% (n=1589) | 94.6% (n=536) | 99.6% (n=230) | 99.5% (n=631) | 2986 | 18.54 | <0.001 | +| all | 96.3% (n=1607) | 94.6% (n=542) | 99.6% (n=230) | 99.5% (n=651) | 3030 | 18.92 | <0.001 | | pre-2024 | 96.2% (n=1247) | 91.9% (n=357) | 90.0% (n=10) | 99.3% (n=297) | 1911 | 2.69 | 0.101 | -| post-2024 | 96.5% (n=342) | 100.0% (n=179) | 100.0% (n=220) | 99.7% (n=334) | 1075 | 14.24 | <0.001 | +| post-2024 | 96.7% (n=360) | 100.0% (n=185) | 100.0% (n=220) | 99.7% (n=354) | 1119 | 14.05 | <0.001 | | government | 98.1% (n=161) | 96.4% (n=166) | 100.0% (n=82) | 99.5% (n=211) | 620 | 3.00 | 0.083 | | opposition | 96.1% (n=1201) | 93.0% (n=228) | 98.9% (n=89) | 99.5% (n=182) | 1700 | 3.82 | 0.051 | @@ -39,7 +41,7 @@ support quartile (Q4) and the lowest (Q1): pass_rate(Q4) - pass_rate(Q1). |---------|-------------|-------------|---------| | all | 96.3% | 99.5% | +3.2% | | pre-2024 | 96.2% | 99.3% | +3.1% | -| post-2024 | 96.5% | 99.7% | +3.2% | +| post-2024 | 96.7% | 99.7% | +3.1% | | government | 98.1% | 99.5% | +1.4% | | opposition | 96.1% | 99.5% | +3.4% | @@ -72,13 +74,13 @@ Motion type is determined by parsing the lead submitter from the title prefix ## 5. Interpretation -The Cochran-Armitage trend test is significant (χ²=18.54, p=0.000), indicating a positive monotonic relationship between centrist support and pass rate. The success premium is +3.2%. +The Cochran-Armitage trend test is significant (χ²=18.92, p=0.000), indicating a positive monotonic relationship between centrist support and pass rate. The success premium is +3.2%. For opposition motions specifically, the trend test is not significant (χ²=3.82, p=0.051). ### Period Comparison - **pre-2024** (n=1911): χ²=2.69, p=0.101, premium=+3.1% -- **post-2024** (n=1075): χ²=14.24, p=0.000, premium=+3.2% +- **post-2024** (n=1119): χ²=14.05, p=0.000, premium=+3.1% --- diff --git a/reports/overton_window/svd_stability_report.md b/reports/overton_window/svd_stability_report.md index 393fd26..672cad9 100644 --- a/reports/overton_window/svd_stability_report.md +++ b/reports/overton_window/svd_stability_report.md @@ -1,5 +1,7 @@ # Center Drift Report (Procrustes-Aligned) +> **Part of the Overton Window Analysis.** See the [synthesis report](overton_window_synthesis.md) for the integrated narrative, or the [interactive article](overton_window.qmd) for the full story with charts. + ## Alignment Method Party positions are Procrustes-aligned across all windows, then PCA-rotated to a common 2D reference frame. This ensures that axis orientation is consistent across time — no stability validation is needed because all positions live in the same coordinate system. diff --git a/reports/overton_window/temporal_trajectory.md b/reports/overton_window/temporal_trajectory.md index b779e68..2eda92c 100644 --- a/reports/overton_window/temporal_trajectory.md +++ b/reports/overton_window/temporal_trajectory.md @@ -1,5 +1,7 @@ # Temporal Trajectory: Centrist Support for Right-Wing Motions +> **Part of the Overton Window Analysis.** See the [synthesis report](overton_window_synthesis.md) for the integrated narrative, or the [interactive article](overton_window.qmd) for the full story with charts. + **Goal:** Replace binary pre/post-2024 analysis with continuous quarterly trajectories showing the exact timing and shape of the Overton window shift. diff --git a/reports/overton_window/voting_margin.md b/reports/overton_window/voting_margin.md index e54708f..7e441c4 100644 --- a/reports/overton_window/voting_margin.md +++ b/reports/overton_window/voting_margin.md @@ -1,12 +1,14 @@ # Voting Margin Analysis +> **Part of the Overton Window Analysis.** See the [synthesis report](overton_window_synthesis.md) for the integrated narrative, or the [interactive article](overton_window.qmd) for the full story with charts. + **Goal:** Replace binary pass/fail with continuous voting margin as the primary success metric for right-wing motions in the Tweede Kamer. **Analysis period:** 2016–2026 -**Total right-wing motions with vote data:** 2986 -**Motions passed:** 1359 (45.5%) -**Motions failed:** 1627 (54.5%) +**Total right-wing motions with vote data:** 3030 +**Motions passed:** 1388 (45.8%) +**Motions failed:** 1642 (54.2%) --- @@ -40,12 +42,12 @@ stronger parliamentary consensus. | Metric | Value | |--------|-------| -| Spearman ρ | 0.812 | +| Spearman ρ | 0.814 | | Spearman p-value | 0.0e+00 | -| Pearson r | 0.822 | +| Pearson r | 0.824 | | Pearson p-value | 0.0e+00 | -The Spearman correlation is significant (ρ = 0.812, p = 0.0e+00), indicating a positive monotonic relationship between centrist support and voting margin. +The Spearman correlation is significant (ρ = 0.814, p = 0.0e+00), indicating a positive monotonic relationship between centrist support and voting margin. --- @@ -55,19 +57,19 @@ The Spearman correlation is significant (ρ = 0.812, p = 0.0e+00), indicating a | Stratum | Q1 [0.00–0.25] | Q2 (0.25–0.50] | Q3 (0.50–0.75] | Q4 (0.75–1.00] | |---------|:------:|:------:|:------:|:------:| -| all | -0.263 (n=1589) | +0.087 (n=536) | +0.212 (n=230) | +0.483 (n=631) | +| all | -0.262 (n=1607) | +0.089 (n=542) | +0.212 (n=230) | +0.485 (n=651) | | pre-2024 | -0.261 (n=1247) | +0.122 (n=357) | +0.232 (n=10) | +0.420 (n=297) | -| post-2024 | -0.269 (n=342) | +0.017 (n=179) | +0.211 (n=220) | +0.539 (n=334) | +| post-2024 | -0.263 (n=360) | +0.026 (n=185) | +0.211 (n=220) | +0.539 (n=354) | ### Detailed Statistics (All Motions) | Quartile | N | Mean | Median | Std | P25 | P75 | Min | Max | |----------|---|------|--------|-----|-----|-----|-----|-----| -| Q1 | 1589 | -0.263 | -0.294 | 0.228 | -0.450 | -0.100 | -0.733 | +0.438 | -| Q2 | 536 | +0.087 | +0.067 | 0.220 | -0.067 | +0.238 | -0.467 | +0.625 | +| Q1 | 1607 | -0.262 | -0.294 | 0.228 | -0.450 | -0.100 | -0.733 | +0.438 | +| Q2 | 542 | +0.089 | +0.067 | 0.220 | -0.067 | +0.238 | -0.467 | +0.625 | | Q3 | 230 | +0.212 | +0.200 | 0.165 | +0.067 | +0.333 | -0.200 | +0.600 | -| Q4 | 631 | +0.483 | +0.467 | 0.173 | +0.368 | +0.600 | -0.125 | +0.765 | +| Q4 | 651 | +0.485 | +0.467 | 0.174 | +0.368 | +0.600 | -0.125 | +0.765 | **Q4 – Q1 gap in mean margin:** +0.746 @@ -83,10 +85,10 @@ metric to determine whether margin captures additional information. | Quartile | N | Pass Rate | Mean Margin | |----------|---|-----------|-------------| -| Q1 | 1589 | 12.7% | -0.263 | -| Q2 | 536 | 59.3% | +0.087 | +| Q1 | 1607 | 12.8% | -0.262 | +| Q2 | 542 | 59.8% | +0.089 | | Q3 | 230 | 92.6% | +0.212 | -| Q4 | 631 | 99.2% | +0.483 | +| Q4 | 651 | 99.2% | +0.485 | **Pass rate gap (Q4 – Q1):** +86.5% @@ -100,11 +102,12 @@ Both pass rate and margin show a positive relationship with centrist support. Ma | Metric | Pre-2024 | Post-2024 | Δ | |--------|----------|-----------|-----| -| N | 1911 | 1075 | | -| Mean margin | -0.081 | +0.128 | +0.209 | -| Mann-Whitney U | | | U=702132, p=6.6e-47 | -| Cohen's d | | | +0.582 | +| N | 1911 | 1119 | | +| Mean margin | -0.081 | +0.131 | +0.213 | +| Mann-Whitney U | | | U=725110, p=1.2e-49 | +| Cohen's d | | | +0.593 | +Voting margin rose significantly post-2024 (Mann-Whitney p = 1.2e-49, d = +0.593). --- @@ -121,14 +124,14 @@ Both pass rate and margin show a positive relationship with centrist support. Ma | 2023 | 365 | -0.077 | 0.255 | 34.2% | | 2024 | 469 | +0.175 | 0.595 | 69.5% | | 2025 | 455 | +0.089 | 0.474 | 57.4% | -| 2026 | 151 | +0.099 | 0.334 | 47.7% | +| 2026 | 195 | +0.127 | 0.376 | 51.8% | --- ## 7. Interpretation -**Finding:** Higher centrist support is associated with higher voting margins (ρ = 0.812, p = 0.0e+00). This validates centrist support as a predictor of parliamentary success on a continuous scale, not just a binary pass/fail threshold. +**Finding:** Higher centrist support is associated with higher voting margins (ρ = 0.814, p = 0.0e+00). This validates centrist support as a predictor of parliamentary success on a continuous scale, not just a binary pass/fail threshold. **Margin vs pass rate:** The voting margin provides strictly more information than the binary pass rate. Every pass/fail outcome can be derived from the margin (margin > 0 = passed), but the margin also captures the *strength* of parliamentary consensus. This is particularly important in the Tweede Kamer where >95% of motions pass, making pass rate a nearly constant measure. diff --git a/reports/overton_window/voting_margin_figure.png b/reports/overton_window/voting_margin_figure.png index 9ef456a..01fa2e3 100644 Binary files a/reports/overton_window/voting_margin_figure.png and b/reports/overton_window/voting_margin_figure.png differ