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".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".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".name})", - f".name})", + f".name})", f".name})", + f".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 @@ - - -
- - -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.
- - -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.
- -| Indicator | Pre-2024 | Post-2024 | Change | Verdict |
|---|---|---|---|---|
| Centrist support (strict) | 0.251 | 0.507 | +0.256 | Surged |
| Material impact (2D) | 2.78 | 2.43 | −0.35 | Declined |
| High-impact share (M≥4) | 23.7% | 11.3% | −12.4 pp | Declined |
| SVD cultural gap | 0.282 | 0.428 | +0.146 | Diverged |
| Stylistic extremity | 1.718 | 1.815 | +0.097 | Increased |
| Temporal trajectory | — | — | — | Electoral 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.
- -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.
- -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.
- -Fraction of centrist parties voting 'voor' on opposition right-wing motions. Cohen's d = +0.85.
-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.
- -The shift is not uniform across centrist parties:
- -| Party | Pre-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.
- -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.
- -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 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:
- -| Dimension | Pre-2024 | Post-2024 | Change |
|---|---|---|---|
| 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 |
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.
- -Both on 1–5 scale. Pre (lighter) vs Post (darker). Stylistic rose while material fell — dimensions systematically diverge.
-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 Level | Pre-2024 CS | Post-2024 CS | Δ |
|---|---|---|---|
| All motions | 0.254 | 0.509 | +0.255 |
| M≥3 (substantive policy) | 0.192 | 0.435 | +0.243 |
| M≥4 (fundamental rights) | 0.114 | 0.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.
- -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):
- -Pre-2024 (lighter) vs Post-2024 (darker). Left-wing motions: FLAT. Right-wing motions: 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.
- -Treating right-wing parties as a bloc obscures a critical finding. Breaking down by party:
- -| Party | CS Shift | Volume Change | Notable |
|---|---|---|---|
| JA21 | +0.203 | +82 | Only party with volume + support gains |
| SGP | +0.195 | −91 | Already mainstream pre-2024 |
| PVV | +0.125 | −185 | Entered government, filed fewer motions |
| FVD | +0.036 | −62 | Remains 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.
- -A systematic classification of 200 motions across 10 mechanism types reveals the dominant pathways through which right-wing motions gain centrist support:
- -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.
- -Quarterly analysis across 33 quarters (2016-Q2 through 2026-Q1) reveals the exact timing:
- -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.
- -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)
- -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.
- -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.
- -| Feature | Coefficient | Interpretation |
|---|---|---|
| Category: Corona | −1.47 | Corona motions get 77% lower odds of centrist support |
| Submitter: FVD | −1.33 | FVD motions get 73% lower odds |
| Submitter: SGP | +0.99 | SGP motions get 2.7× higher odds |
| Submitter: JA21 | +0.93 | JA21 motions get 2.5× higher odds |
| Stylistic extremity | −0.69 | Each 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.
- -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.
- -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).
-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.
+ + +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.
+ + +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.
+ +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.
+ +Three motions illustrate different patterns in the Overton window shift.
+ +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.
+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.
+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.
+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.
+ +| Year | +Count (M3+) | +CS strict | +Change | +
|---|---|---|---|
| 2016 | +17 | +0.705 | +— | +
| 2017 | +9 | +0.664 | +-0.041 | +
| 2018 | +12 | +0.958 | ++0.294 | +
| 2019 | +851 | +0.336 | +-0.622 | +
| 2020 | +1,157 | +0.308 | +-0.028 | +
| 2021 | +1,229 | +0.329 | ++0.021 | +
| 2022 | +1,199 | +0.313 | +-0.016 | +
| 2023 | +1,099 | +0.355 | ++0.042 | +
| 2024 | +1,252 | +0.487 | ++0.132 | +
| 2025 | +1,101 | +0.451 | +-0.036 | +
| 2026 | +404 | +0.297 | +-0.154 | +
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.
+ +N: 1,911 pre · 1,119 post
+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.
+ + +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 \ Materieel | +1 | +2 | +3 | +4 | +5 | +
|---|---|---|---|---|---|
| 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% |
+