feat(overton): coherent narrative architecture — Quarto article, Explorer Overton tab, report cleanup
- U1: Remove stale findings_report.md and blog_post.html, add cross-reference headers to all 13 appendix reports, switch HTML report to canonical 4-party centrist definition - U2: Create Quarto narrative spine (overton_window.qmd) with 9 sections and 6 interactive Plotly charts. Includes 'About Stemwijzer' platform section. - U3: Add Overton tab to Explorer (centrist support trend, right-wing motion browser, explore-further links). Add Overton context expander to Kompas tab and 2024 breakpoint annotation to Trajectories tab. - U4: Create build_all_reports.py master regeneration script (3-phase, dependency-ordered, --skip-llm support) - U5: Update README with Research section, create reports/overton_window/README.md reading guide, update STATUS.md with broader platform framing Plan: docs/plans/2026-06-06-001-overton-coherent-narrative-plan.md 282 tests pass.main
@ -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()) |
||||
@ -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()) |
||||
@ -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" |
||||
) |
||||
@ -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/ |
||||
@ -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 |
||||
@ -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 |
||||
@ -0,0 +1,3 @@ |
||||
/.quarto/ |
||||
/_render/ |
||||
**/*.quarto_ipynb |
||||
@ -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. |
||||
@ -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.** |
||||
@ -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 |
||||
@ -1,588 +0,0 @@ |
||||
<!DOCTYPE html> |
||||
<html lang="en"> |
||||
<head> |
||||
<meta charset="UTF-8"> |
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||
<title>Has the Overton Window Shifted in the Dutch Parliament?</title> |
||||
<style> |
||||
:root { |
||||
--blue: #2563eb; |
||||
--red: #dc2626; |
||||
--green: #16a34a; |
||||
--gray: #6b7280; |
||||
--light: #f8fafc; |
||||
--border: #e2e8f0; |
||||
--text: #1e293b; |
||||
--muted: #64748b; |
||||
--accent: #7c3aed; |
||||
} |
||||
* { box-sizing: border-box; margin: 0; padding: 0; } |
||||
body { |
||||
font-family: 'Georgia', serif; |
||||
color: var(--text); |
||||
line-height: 1.75; |
||||
max-width: 720px; |
||||
margin: 0 auto; |
||||
padding: 2rem 1.5rem; |
||||
background: #fff; |
||||
} |
||||
h1 { |
||||
font-size: 2.25rem; |
||||
line-height: 1.2; |
||||
margin-bottom: 0.5rem; |
||||
font-weight: 700; |
||||
letter-spacing: -0.02em; |
||||
} |
||||
.subtitle { |
||||
font-size: 1.15rem; |
||||
color: var(--muted); |
||||
margin-bottom: 2rem; |
||||
font-style: italic; |
||||
} |
||||
.byline { |
||||
font-size: 0.9rem; |
||||
color: var(--muted); |
||||
margin-bottom: 2.5rem; |
||||
border-bottom: 1px solid var(--border); |
||||
padding-bottom: 1.5rem; |
||||
} |
||||
h2 { |
||||
font-size: 1.5rem; |
||||
margin-top: 2.5rem; |
||||
margin-bottom: 1rem; |
||||
font-weight: 700; |
||||
letter-spacing: -0.01em; |
||||
} |
||||
h3 { |
||||
font-size: 1.2rem; |
||||
margin-top: 2rem; |
||||
margin-bottom: 0.75rem; |
||||
font-weight: 600; |
||||
} |
||||
p { margin-bottom: 1.25rem; } |
||||
.callout { |
||||
background: var(--light); |
||||
border-left: 4px solid var(--blue); |
||||
padding: 1.25rem 1.5rem; |
||||
margin: 2rem 0; |
||||
font-size: 1.05rem; |
||||
} |
||||
.callout.warning { |
||||
border-left-color: var(--red); |
||||
background: #fef2f2; |
||||
} |
||||
.callout.success { |
||||
border-left-color: var(--green); |
||||
background: #f0fdf4; |
||||
} |
||||
.verdict { |
||||
background: #faf5ff; |
||||
border-left: 4px solid var(--accent); |
||||
padding: 1.5rem; |
||||
margin: 2rem 0; |
||||
font-size: 1.1rem; |
||||
font-weight: 600; |
||||
} |
||||
table { |
||||
width: 100%; |
||||
border-collapse: collapse; |
||||
margin: 1.5rem 0; |
||||
font-size: 0.95rem; |
||||
} |
||||
th, td { |
||||
padding: 0.6rem 0.8rem; |
||||
text-align: left; |
||||
border-bottom: 1px solid var(--border); |
||||
} |
||||
th { |
||||
font-weight: 600; |
||||
background: var(--light); |
||||
font-size: 0.85rem; |
||||
text-transform: uppercase; |
||||
letter-spacing: 0.05em; |
||||
color: var(--muted); |
||||
} |
||||
td:not(:first-child) { text-align: right; } |
||||
.chart { |
||||
margin: 2rem 0; |
||||
background: var(--light); |
||||
border-radius: 8px; |
||||
padding: 1.5rem; |
||||
overflow-x: auto; |
||||
} |
||||
.chart-title { |
||||
font-size: 0.85rem; |
||||
font-weight: 600; |
||||
text-transform: uppercase; |
||||
letter-spacing: 0.05em; |
||||
color: var(--muted); |
||||
margin-bottom: 1rem; |
||||
} |
||||
.bar-chart { display: flex; flex-direction: column; gap: 0.5rem; } |
||||
.bar-row { display: flex; align-items: center; gap: 0.75rem; } |
||||
.bar-label { width: 120px; font-size: 0.85rem; text-align: right; flex-shrink: 0; } |
||||
.bar-track { flex: 1; height: 28px; background: #e2e8f0; border-radius: 4px; position: relative; } |
||||
.bar-fill { height: 100%; border-radius: 4px; display: flex; align-items: center; padding-left: 8px; font-size: 0.8rem; color: #fff; font-weight: 600; min-width: 40px; } |
||||
.bar-fill.blue { background: var(--blue); } |
||||
.bar-fill.red { background: var(--red); } |
||||
.bar-fill.green { background: var(--green); } |
||||
.bar-fill.gray { background: var(--gray); } |
||||
.bar-fill.purple { background: var(--accent); } |
||||
.bar-value { font-size: 0.85rem; font-weight: 600; width: 50px; text-align: left; flex-shrink: 0; } |
||||
.dual-bar { display: flex; flex-direction: column; gap: 0.25rem; margin-bottom: 0.75rem; } |
||||
.dual-bar .bar-row { height: 20px; } |
||||
.dual-label { font-size: 0.85rem; margin-bottom: 0.15rem; font-weight: 500; } |
||||
.metric-grid { |
||||
display: grid; |
||||
grid-template-columns: 1fr 1fr; |
||||
gap: 1rem; |
||||
margin: 1.5rem 0; |
||||
} |
||||
.metric-card { |
||||
background: var(--light); |
||||
border-radius: 8px; |
||||
padding: 1.25rem; |
||||
text-align: center; |
||||
} |
||||
.metric-value { |
||||
font-size: 2rem; |
||||
font-weight: 700; |
||||
line-height: 1; |
||||
} |
||||
.metric-value.up { color: var(--blue); } |
||||
.metric-value.down { color: var(--red); } |
||||
.metric-value.flat { color: var(--gray); } |
||||
.metric-desc { |
||||
font-size: 0.8rem; |
||||
color: var(--muted); |
||||
margin-top: 0.4rem; |
||||
} |
||||
.timeline { |
||||
margin: 2rem 0; |
||||
position: relative; |
||||
padding-left: 2rem; |
||||
} |
||||
.timeline::before { |
||||
content: ''; |
||||
position: absolute; |
||||
left: 8px; |
||||
top: 0; |
||||
bottom: 0; |
||||
width: 2px; |
||||
background: var(--border); |
||||
} |
||||
.timeline-event { |
||||
position: relative; |
||||
margin-bottom: 1.5rem; |
||||
} |
||||
.timeline-event::before { |
||||
content: ''; |
||||
position: absolute; |
||||
left: -1.55rem; |
||||
top: 6px; |
||||
width: 10px; |
||||
height: 10px; |
||||
border-radius: 50%; |
||||
background: var(--blue); |
||||
} |
||||
.timeline-event.key::before { background: var(--red); width: 12px; height: 12px; left: -1.6rem; } |
||||
.timeline-date { font-size: 0.8rem; color: var(--muted); font-weight: 600; } |
||||
.timeline-text { font-size: 0.95rem; } |
||||
.footnote { |
||||
font-size: 0.85rem; |
||||
color: var(--muted); |
||||
border-top: 1px solid var(--border); |
||||
margin-top: 3rem; |
||||
padding-top: 1.5rem; |
||||
} |
||||
.footnote p { margin-bottom: 0.5rem; } |
||||
.highlight { background: #fef3c7; padding: 0.15em 0.3em; border-radius: 3px; } |
||||
strong { font-weight: 700; } |
||||
em { font-style: italic; } |
||||
.arrow { display: inline-block; font-weight: 700; } |
||||
.arrow.up { color: var(--blue); } |
||||
.arrow.down { color: var(--red); } |
||||
.arrow.flat { color: var(--gray); } |
||||
@media (max-width: 600px) { |
||||
body { padding: 1rem; } |
||||
h1 { font-size: 1.75rem; } |
||||
.metric-grid { grid-template-columns: 1fr; } |
||||
.bar-label { width: 80px; font-size: 0.75rem; } |
||||
} |
||||
</style> |
||||
</head> |
||||
<body> |
||||
|
||||
<h1>Has the Overton Window Shifted in the Dutch Parliament?</h1> |
||||
<p class="subtitle">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.</p> |
||||
<p class="byline">Analysis based on 2,986 classified right-wing motions, 2,869 two-dimensional extremity scores, MP-level voting records across 33 quarters, and 200 systematically classified policy mechanisms.</p> |
||||
|
||||
<h2>The Question</h2> |
||||
|
||||
<p>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: <strong>has the Overton window shifted to the right?</strong> Have centrist parties (VVD, D66, CDA, NSC, ChristenUnie, BBB) become more accepting of right-wing policy positions?</p> |
||||
|
||||
<p>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.</p> |
||||
|
||||
<p>The answer is more nuanced — and more interesting — than a simple yes or no.</p> |
||||
|
||||
<div class="verdict"> |
||||
The Overton window did not shift right. Right-wing parties moderated toward it. That moderation effect may be temporary. |
||||
</div> |
||||
|
||||
<h2>Three Indicators at a Glance</h2> |
||||
|
||||
<table> |
||||
<tr><th>Indicator</th><th>Pre-2024</th><th>Post-2024</th><th>Change</th><th>Verdict</th></tr> |
||||
<tr><td>Centrist support (strict)</td><td>0.251</td><td>0.507</td><td><span class="arrow up">+0.256</span></td><td>Surged</td></tr> |
||||
<tr><td>Material impact (2D)</td><td>2.78</td><td>2.43</td><td><span class="arrow down">−0.35</span></td><td>Declined</td></tr> |
||||
<tr><td>High-impact share (M≥4)</td><td>23.7%</td><td>11.3%</td><td><span class="arrow down">−12.4 pp</span></td><td>Declined</td></tr> |
||||
<tr><td>SVD cultural gap</td><td>0.282</td><td>0.428</td><td><span class="arrow up">+0.146</span></td><td>Diverged</td></tr> |
||||
<tr><td>Stylistic extremity</td><td>1.718</td><td>1.815</td><td><span class="arrow up">+0.097</span></td><td>Increased</td></tr> |
||||
<tr><td>Temporal trajectory</td><td>—</td><td>—</td><td>—</td><td>Electoral jump, reverting</td></tr> |
||||
</table> |
||||
|
||||
<p>Centrist support surged. But the motions themselves became <em>less</em> 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 <em>toward</em> the window: they filed more motions, with milder content, framed in centrist-friendly language.</p> |
||||
|
||||
<h2>Indicator 1: How Centrists Voted</h2> |
||||
|
||||
<p>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.</p> |
||||
|
||||
<h3>Not a Coalition Effect</h3> |
||||
|
||||
<p>After the Schoof cabinet formed, PVV entered government, which could mechanically inflate support for its own motions. So we restricted to <strong>opposition-only</strong> right-wing motions. The effect is <em>larger</em>: d = +0.85, with support jumping from 0.270 to 0.543. Coalition dynamics slightly <em>suppressed</em> the observable shift.</p> |
||||
|
||||
<div class="chart"> |
||||
<div class="chart-title">Centrist Support for Right-Wing Motions (Opposition-Only)</div> |
||||
<div class="bar-chart"> |
||||
<div class="bar-row"> |
||||
<div class="bar-label">Pre-2024</div> |
||||
<div class="bar-track"><div class="bar-fill blue" style="width: 27%">0.270</div></div> |
||||
</div> |
||||
<div class="bar-row"> |
||||
<div class="bar-label">Post-2024</div> |
||||
<div class="bar-track"><div class="bar-fill blue" style="width: 54.3%">0.543</div></div> |
||||
</div> |
||||
</div> |
||||
<p style="font-size: 0.8rem; color: var(--muted); margin-top: 0.75rem;">Fraction of centrist parties voting 'voor' on opposition right-wing motions. Cohen's d = +0.85.</p> |
||||
</div> |
||||
|
||||
<h3>The Gradient Persists</h3> |
||||
|
||||
<p>Centrists still differentiate by how radical a motion is — high-extremity motions (buckets 3–5) gained proportionally <em>more</em> support than mild motions (buckets 1–2). This is consistent with genuine tolerance expansion, not a compositional shift toward milder motions.</p> |
||||
|
||||
<h3>Who Drove the Shift?</h3> |
||||
|
||||
<p>The shift is not uniform across centrist parties:</p> |
||||
|
||||
<table> |
||||
<tr><th>Party</th><th>Pre-2024 Migration Voor%</th><th>Post-2024 Migration Voor%</th></tr> |
||||
<tr><td>CDA</td><td>~18%</td><td>~40%</td></tr> |
||||
<tr><td>ChristenUnie</td><td>~10%</td><td>~30%</td></tr> |
||||
<tr><td>NSC</td><td>—</td><td>~30%</td></tr> |
||||
<tr><td>D66</td><td>~4%</td><td>~12%</td></tr> |
||||
</table> |
||||
|
||||
<p>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 <strong>the Christian-conservative wing of the center moved substantially, while the progressive wing barely budged</strong>.</p> |
||||
|
||||
<h2>Indicator 2: Spatial Divergence</h2> |
||||
|
||||
<p>If centrists are voting more with right-wing motions, one might expect ideological convergence — centrist parties drifting rightward. Procrustes-aligned SVD analysis shows the <em>opposite</em>.</p> |
||||
|
||||
<div class="metric-grid"> |
||||
<div class="metric-card"> |
||||
<div class="metric-value down">−0.30</div> |
||||
<div class="metric-desc">Centrist axis-1 drift (leftward, more welfare)</div> |
||||
</div> |
||||
<div class="metric-card"> |
||||
<div class="metric-value flat">+0.07</div> |
||||
<div class="metric-desc">Right-wing axis-1 drift (barely moved)</div> |
||||
</div> |
||||
<div class="metric-card"> |
||||
<div class="metric-value up">+0.146</div> |
||||
<div class="metric-desc">Cultural gap widened</div> |
||||
</div> |
||||
<div class="metric-card"> |
||||
<div class="metric-value up">160°</div> |
||||
<div class="metric-desc">Centrist drift direction (southwest: welfare + cosmopolitan)</div> |
||||
</div> |
||||
</div> |
||||
|
||||
<p>Centrist parties moved <em>left</em> 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.</p> |
||||
|
||||
<p>This is spatial <em>divergence</em>, not convergence. The puzzle: how can centrists vote more with right-wing motions while moving further away from them ideologically?</p> |
||||
|
||||
<div class="callout"> |
||||
<strong>The resolution:</strong> Right-wing parties filed a much larger volume of milder motions that centrists supported, while continuing to file high-impact motions centrists opposed. The net effect on SVD was centrist-left divergence: the extreme motions (still opposed) dominated the voting structure, while the surge of milder centrist-supported motions added volume without shifting party positions. |
||||
</div> |
||||
|
||||
<h2>Indicator 3: Content Extremity — The 2D Story</h2> |
||||
|
||||
<p>The original single-dimensional extremity score showed no increase post-2024 (d = −0.09). But a single score conflates two independent dimensions:</p> |
||||
|
||||
<ul style="margin: 1rem 0 1.5rem 1.5rem;"> |
||||
<li><strong>Stylistic extremity</strong> — how inflammatory is the language?</li> |
||||
<li><strong>Material impact</strong> — how consequential is the proposed policy?</li> |
||||
</ul> |
||||
|
||||
<p>These two dimensions are only moderately correlated (r = 0.47). And their trajectories <em>diverge</em>:</p> |
||||
|
||||
<table> |
||||
<tr><th>Dimension</th><th>Pre-2024</th><th>Post-2024</th><th>Change</th></tr> |
||||
<tr><td>Stylistic extremity</td><td>1.718</td><td>1.815</td><td><span class="arrow up">+0.097</span></td></tr> |
||||
<tr><td>Material impact</td><td>2.530</td><td>2.384</td><td><span class="arrow down">−0.146</span></td></tr> |
||||
<tr><td>Gap (M−S)</td><td>0.813</td><td>0.570</td><td><span class="arrow down">−0.243</span></td></tr> |
||||
</table> |
||||
|
||||
<p>Right-wing motions became <em>more restrained</em> in language while simultaneously becoming <em>less materially consequential</em>. This is holistic moderation — both the packaging and the content shifted toward the center.</p> |
||||
|
||||
<div class="chart"> |
||||
<div class="chart-title">2D Extremity Divergence (Wilcoxon p = 0.002)</div> |
||||
<div class="bar-chart"> |
||||
<div class="dual-bar"> |
||||
<div class="dual-label">Stylistic extremity</div> |
||||
<div class="bar-row"> |
||||
<div class="bar-track"><div class="bar-fill red" style="width: 34.4%">1.718</div></div> |
||||
</div> |
||||
<div class="bar-row"> |
||||
<div class="bar-track"><div class="bar-fill red" style="width: 36.3%">1.815</div></div> |
||||
</div> |
||||
</div> |
||||
<div class="dual-bar"> |
||||
<div class="dual-label">Material impact</div> |
||||
<div class="bar-row"> |
||||
<div class="bar-track"><div class="bar-fill blue" style="width: 50.6%">2.530</div></div> |
||||
</div> |
||||
<div class="bar-row"> |
||||
<div class="bar-track"><div class="bar-fill blue" style="width: 47.7%">2.384</div></div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
<p style="font-size: 0.8rem; color: var(--muted); margin-top: 0.5rem;">Both on 1–5 scale. Pre (lighter) vs Post (darker). Stylistic rose while material fell — dimensions systematically diverge.</p> |
||||
</div> |
||||
|
||||
<h2>The Gravity Question: Do Meaningful Motions Show the Same Pattern?</h2> |
||||
|
||||
<p>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?</p> |
||||
|
||||
<table> |
||||
<tr><th>Gravity Level</th><th>Pre-2024 CS</th><th>Post-2024 CS</th><th>Δ</th></tr> |
||||
<tr><td>All motions</td><td>0.254</td><td>0.509</td><td><span class="arrow up">+0.255</span></td></tr> |
||||
<tr><td>M≥3 (substantive policy)</td><td>0.192</td><td>0.435</td><td><span class="arrow up">+0.243</span></td></tr> |
||||
<tr><td>M≥4 (fundamental rights)</td><td>0.114</td><td>0.377</td><td><span class="arrow up">+0.263</span></td></tr> |
||||
</table> |
||||
|
||||
<p><strong>The shift is real across ALL gravity levels</strong>, including motions that touch fundamental rights. If anything, the effect is slightly <em>larger</em> 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.</p> |
||||
|
||||
<h2>The Left-Wing Control: Are Centrists Drifting Left or Right?</h2> |
||||
|
||||
<p>A key concern: the SVD shows centrists moving left. Could this mean they're simply voting more with <em>everyone</em> — including left-wing parties — rather than specifically accommodating the right?</p> |
||||
|
||||
<p>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):</p> |
||||
|
||||
<div class="chart"> |
||||
<div class="chart-title">Centrist Support by Submitting Party Bloc</div> |
||||
<div class="bar-chart"> |
||||
<div class="dual-bar"> |
||||
<div class="dual-label">Left-wing motions</div> |
||||
<div class="bar-row"> |
||||
<div class="bar-track"><div class="bar-fill green" style="width: 49.8%">0.498</div></div> |
||||
</div> |
||||
<div class="bar-row"> |
||||
<div class="bar-track"><div class="bar-fill green" style="width: 49.0%">0.490</div></div> |
||||
</div> |
||||
</div> |
||||
<div class="dual-bar"> |
||||
<div class="dual-label">Right-wing motions</div> |
||||
<div class="bar-row"> |
||||
<div class="bar-track"><div class="bar-fill red" style="width: 36.9%">0.369</div></div> |
||||
</div> |
||||
<div class="bar-row"> |
||||
<div class="bar-track"><div class="bar-fill red" style="width: 53.1%">0.531</div></div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
<p style="font-size: 0.8rem; color: var(--muted); margin-top: 0.5rem;">Pre-2024 (lighter) vs Post-2024 (darker). Left-wing motions: FLAT. Right-wing motions: SURGE.</p> |
||||
</div> |
||||
|
||||
<div class="callout success"> |
||||
<strong>Left-wing motions:</strong> Pre CS=0.498 → Post CS=0.490, Δ=−0.008. <strong>Completely flat.</strong><br> |
||||
<strong>Right-wing motions:</strong> Pre CS=0.369 → Post CS=0.531, Δ=+0.162. <strong>Surge.</strong> |
||||
</div> |
||||
|
||||
<p>The centrist support surge is <em>entirely</em> concentrated in right-wing motions. Centrist support for left-wing motions didn't change at all. The SVD's leftward drift is <em>not</em> 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.</p> |
||||
|
||||
<h2>Who Filed the Motions? JA21 as the Primary Driver</h2> |
||||
|
||||
<p>Treating right-wing parties as a bloc obscures a critical finding. Breaking down by party:</p> |
||||
|
||||
<table> |
||||
<tr><th>Party</th><th>CS Shift</th><th>Volume Change</th><th>Notable</th></tr> |
||||
<tr><td><strong>JA21</strong></td><td><span class="arrow up">+0.203</span></td><td>+82</td><td>Only party with volume + support gains</td></tr> |
||||
<tr><td>SGP</td><td><span class="arrow up">+0.195</span></td><td>−91</td><td>Already mainstream pre-2024</td></tr> |
||||
<tr><td>PVV</td><td><span class="arrow up">+0.125</span></td><td>−185</td><td>Entered government, filed fewer motions</td></tr> |
||||
<tr><td>FVD</td><td><span class="arrow up">+0.036</span></td><td>−62</td><td>Remains essentially shunned</td></tr> |
||||
</table> |
||||
|
||||
<p><strong>JA21 drives the moderation effect</strong> — they are the only party that both significantly increased motion volume <em>and</em> 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.</p> |
||||
|
||||
<h2>How Did They Do It? Mechanism Classification</h2> |
||||
|
||||
<p>A systematic classification of 200 motions across 10 mechanism types reveals the dominant pathways through which right-wing motions gain centrist support:</p> |
||||
|
||||
<h3>Post-2024 High-Support Motions (CS > 0.5)</h3> |
||||
|
||||
<div class="chart"> |
||||
<div class="chart-title">Mechanism Distribution (High Centrist Support, Post-2024)</div> |
||||
<div class="bar-chart"> |
||||
<div class="bar-row"> |
||||
<div class="bar-label">Procedural</div> |
||||
<div class="bar-track"><div class="bar-fill blue" style="width: 64%">32%</div></div> |
||||
</div> |
||||
<div class="bar-row"> |
||||
<div class="bar-label">Consensus</div> |
||||
<div class="bar-track"><div class="bar-fill green" style="width: 48%">24%</div></div> |
||||
</div> |
||||
<div class="bar-row"> |
||||
<div class="bar-label">Restriction</div> |
||||
<div class="bar-track"><div class="bar-fill red" style="width: 34.6%">17.3%</div></div> |
||||
</div> |
||||
<div class="bar-row"> |
||||
<div class="bar-label">Institutional</div> |
||||
<div class="bar-track"><div class="bar-fill gray" style="width: 18.6%">9.3%</div></div> |
||||
</div> |
||||
<div class="bar-row"> |
||||
<div class="bar-label">Other</div> |
||||
<div class="bar-track"><div class="bar-fill purple" style="width: 26.4%">13.3%</div></div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
|
||||
<p>The contrast with low-support motions is sharp. <strong>Zero system-dismantling proposals</strong> (asylum stops, treaty exits, fundamental institutional upheaval) achieved high centrist support post-2024. The truly ideological right-wing agenda does not gain centrist support.</p> |
||||
|
||||
<p>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.</p> |
||||
|
||||
<h2>When Did It Happen? The Temporal Trajectory</h2> |
||||
|
||||
<p>Quarterly analysis across 33 quarters (2016-Q2 through 2026-Q1) reveals the exact timing:</p> |
||||
|
||||
<div class="timeline"> |
||||
<div class="timeline-event"> |
||||
<div class="timeline-date">2016–2023</div> |
||||
<div class="timeline-text">Stable baseline. Mean centrist support: 0.336 across 25 quarters.</div> |
||||
</div> |
||||
<div class="timeline-event key"> |
||||
<div class="timeline-date">November 2023</div> |
||||
<div class="timeline-text"><strong>PVV election victory.</strong> The electoral shock.</div> |
||||
</div> |
||||
<div class="timeline-event key"> |
||||
<div class="timeline-date">2024-Q1</div> |
||||
<div class="timeline-text"><strong>Structural break.</strong> Centrist support jumps from 0.321 → 0.501 (+0.180 in a single quarter). This is 1.9× the average quarterly change.</div> |
||||
</div> |
||||
<div class="timeline-event"> |
||||
<div class="timeline-date">July 2024</div> |
||||
<div class="timeline-text">Schoof cabinet formed. But the shift began <em>before</em> this — ruling out coalition dynamics as the primary driver.</div> |
||||
</div> |
||||
<div class="timeline-event key"> |
||||
<div class="timeline-date">2024-Q4</div> |
||||
<div class="timeline-text"><strong>Peak: 0.648.</strong> First full quarter of the Schoof cabinet.</div> |
||||
</div> |
||||
<div class="timeline-event"> |
||||
<div class="timeline-date">2025-Q1–Q4</div> |
||||
<div class="timeline-text">Steady decline: 0.598 → 0.503 → 0.437 → 0.450.</div> |
||||
</div> |
||||
<div class="timeline-event"> |
||||
<div class="timeline-date">2026-Q1</div> |
||||
<div class="timeline-text"><strong>Reversion: 0.334.</strong> Below the 0.4 inflection threshold. Approaching pre-shift levels.</div> |
||||
</div> |
||||
</div> |
||||
|
||||
<p>The trajectory resembles an electoral response function — a rapid jump after the election, a peak during the honeymoon phase, and a gradual decline. <strong>The shift may be an electoral-cycle phenomenon rather than a permanent Overton window movement.</strong></p> |
||||
|
||||
<h2>The Voting Margin: A Better Metric Than Pass Rate</h2> |
||||
|
||||
<p>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:</p> |
||||
|
||||
<p style="font-family: monospace; font-size: 0.9rem;">margin = (voor − tegen) / (voor + tegen + afwezig)</p> |
||||
|
||||
<div class="metric-grid"> |
||||
<div class="metric-card"> |
||||
<div class="metric-value down">−0.081</div> |
||||
<div class="metric-desc">Pre-2024 mean margin</div> |
||||
</div> |
||||
<div class="metric-card"> |
||||
<div class="metric-value up">+0.128</div> |
||||
<div class="metric-desc">Post-2024 mean margin</div> |
||||
</div> |
||||
<div class="metric-card"> |
||||
<div class="metric-value up">+0.746</div> |
||||
<div class="metric-desc">Q1→Q4 margin gap</div> |
||||
</div> |
||||
<div class="metric-card"> |
||||
<div class="metric-value">ρ=0.812</div> |
||||
<div class="metric-desc">Correlation with centrist support</div> |
||||
</div> |
||||
</div> |
||||
|
||||
<p>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 <em>won</em> by 13 points. The shift from losing to winning is the real signal — not the binary pass/fail.</p> |
||||
|
||||
<h2>Can We Predict Which Motions Succeed?</h2> |
||||
|
||||
<p>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.</p> |
||||
|
||||
<table> |
||||
<tr><th>Feature</th><th>Coefficient</th><th>Interpretation</th></tr> |
||||
<tr><td>Category: Corona</td><td>−1.47</td><td>Corona motions get 77% lower odds of centrist support</td></tr> |
||||
<tr><td>Submitter: FVD</td><td>−1.33</td><td>FVD motions get 73% lower odds</td></tr> |
||||
<tr><td>Submitter: SGP</td><td>+0.99</td><td>SGP motions get 2.7× higher odds</td></tr> |
||||
<tr><td>Submitter: JA21</td><td>+0.93</td><td>JA21 motions get 2.5× higher odds</td></tr> |
||||
<tr><td>Stylistic extremity</td><td>−0.69</td><td>Each point of extremity halves odds</td></tr> |
||||
</table> |
||||
|
||||
<p><strong>Who submits matters more than what the motion says.</strong> 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.</p> |
||||
|
||||
<h2>What This Means</h2> |
||||
|
||||
<p>The Dutch political landscape post-2024 is not best described as "the Overton window shifted right." A more accurate description:</p> |
||||
|
||||
<p>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.</p> |
||||
|
||||
<p>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.</p> |
||||
|
||||
<p>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.</p> |
||||
|
||||
<ol style="margin: 1rem 0 1.5rem 1.5rem;"> |
||||
<li><strong>Right-wing parties strategically moderated.</strong> They filed more motions, with milder content, framed in centrist-friendly language. The share of high-impact proposals (M≥4) dropped from 23.7% to 11.3%.</li> |
||||
<li><strong>Centrist parties rewarded the moderation.</strong> Support surged from 0.251 to 0.507, driven primarily by CDA and ChristenUnie on migration issues.</li> |
||||
<li><strong>The ideological divide held or widened.</strong> SVD spatial analysis shows centrists moved left on both axes while right-wing parties moved further right. The cultural gap widened by +0.146.</li> |
||||
<li><strong>The effect may be temporary.</strong> Quarterly data shows centrist support peaked in 2024-Q4 (0.648) and has since reverted to 0.334 — below the pre-shift threshold.</li> |
||||
<li><strong>Migration is the exception.</strong> The one domain where genuine acceptance expansion (not just content moderation) is measurable. Centrists went from never supporting M=5 migration motions to backing nearly 1 in 5.</li> |
||||
</ol> |
||||
|
||||
<div class="callout warning"> |
||||
<strong>The critical caveat:</strong> This analysis establishes a structural break in centrist voting behavior, not its cause. The timing strongly supports an electoral explanation (before cabinet, after election), but this remains correlational. A proper causal design would require comparison groups we don't have. |
||||
</div> |
||||
|
||||
<div class="verdict"> |
||||
The Overton window did not shift right. Right-wing parties moderated toward it. That moderation effect may be temporary. |
||||
</div> |
||||
|
||||
<h2>Methodology</h2> |
||||
|
||||
<p>This analysis uses data from the Dutch Tweede Kamer OData API, covering 2,986 classified right-wing motions (2016–2026). Key methodological choices:</p> |
||||
|
||||
<ul style="margin: 1rem 0 1.5rem 1.5rem;"> |
||||
<li><strong>Centrist definition:</strong> VVD, D66, CDA, NSC, BBB, ChristenUnie — the six parties closest to the parliamentary center on the Procrustes-aligned SVD axes.</li> |
||||
<li><strong>Extremity scoring:</strong> Two-dimensional (stylistic extremity + material impact), scored by LLM with manual validation (75% agreement). 2,869 motions scored (96% of total).</li> |
||||
<li><strong>SVD alignment:</strong> Procrustes-aligned PCA with flip correction ensuring right-wing parties appear on the right.</li> |
||||
<li><strong>Mechanism classification:</strong> 200 motions classified by LLM, validated with inter-rater reliability (Cohen's κ = 0.41 — moderate agreement, borderline; the taxonomy needs sharper category boundaries, particularly between institutional/rule-of-law and targeted restriction).</li> |
||||
<li><strong>Statistical approach:</strong> Descriptive (Cohen's d) rather than inferential. Small-N time series (8 pre-2024 annual windows, 3 post-2024) limits the power of formal statistical tests.</li> |
||||
</ul> |
||||
|
||||
<div class="footnote"> |
||||
<p><strong>Data sources:</strong> Dutch Tweede Kamer OData API, 2016–2026. All motion texts, voting records, and MP metadata.</p> |
||||
<p><strong>Code:</strong> Analysis scripts in <code>analysis/right_wing/</code>. Reports in <code>reports/overton_window/</code>.</p> |
||||
<p><strong>Reproducibility:</strong> All analyses are deterministic given the same database state. No LLM calls in the final pipeline (scoring was done once; analysis uses stored scores).</p> |
||||
</div> |
||||
|
||||
</body> |
||||
</html> |
||||
|
Before Width: | Height: | Size: 169 KiB After Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 153 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 260 KiB After Width: | Height: | Size: 261 KiB |
|
Before Width: | Height: | Size: 264 KiB After Width: | Height: | Size: 439 KiB |
@ -1,185 +0,0 @@ |
||||
# Overton Window Shift in the Dutch Parliament: Findings Report |
||||
|
||||
**Date:** 2026-05-08 |
||||
**Branch:** feat/right-wing-motion-analysis |
||||
**Analysis period:** 2016–2026 |
||||
|
||||
--- |
||||
|
||||
## 1. Summary |
||||
|
||||
We tested the hypothesis that the Overton window shifted rightward in the Tweede Kamer using three indicators: centrist support for right-wing motions, content extremity trends, and SVD spatial drift. **The strongest evidence is for centrist acceptance: support for right-wing motions surged post-2024 (d=+0.68), and the effect is even larger for opposition-only motions (d=+0.85) — ruling out a pure coalition explanation.** Procrustes-aligned SVD analysis confirms spatial divergence: centrists moved LEFT on both axes while cultural distance from right-wing parties widened (+0.146). Content extremity did not increase (d=−0.09), but LLM scores have known biases. The shift is centered on the migration domain. |
||||
|
||||
--- |
||||
|
||||
## 2. Indicator 1: Centrist Support Breakpoint |
||||
|
||||
### Core finding |
||||
|
||||
Centrist support for right-wing motions rose from a pre-2024 mean of 0.384 to a post-2024 mean of 0.618 — a Cohen's d of +0.68 (medium-large effect). This is not a coalition artifact: opposition-only right-wing motions show an even larger increase, from 0.270 to 0.543 (d=+0.85, large effect). |
||||
|
||||
 |
||||
|
||||
### Pass rate excluded |
||||
|
||||
Pass rate (96%+ both periods) is excluded — Dutch parliament passes nearly all motions, making it a non-diagnostic metric. Centrist support is the primary signal. |
||||
|
||||
### Domain decomposition |
||||
|
||||
The shift is heavily migration-centric: |
||||
|
||||
| Domain | Pre-2024 CS | Post-2024 CS | Δ | |
||||
|--------|------------|-------------|---| |
||||
| Migration | 0.303 | 0.536 | +0.233 | |
||||
| Non-migration | 0.529 | 0.605 | +0.076 | |
||||
|
||||
Migration is the primary vehicle for the observed shift. Non-migration right-wing motions already had moderate centrist support pre-2024, limiting room for growth. |
||||
|
||||
### Extremity-stratified tolerance: Gradient persists |
||||
|
||||
Centrist support rose across all extremity buckets post-2024, but the gradient persists: centrists still differentiate by extremity level, just at a consistently higher baseline. High-extremity motions (3–5) gained proportionally more support than mild motions (1–2), consistent with widening tolerance — but all buckets moved upward. |
||||
|
||||
--- |
||||
|
||||
## 3. Indicator 2: Content Extremity Trend |
||||
|
||||
### Core finding |
||||
|
||||
Content extremity of right-wing motions **did not increase** (pre-2024: 2.21, post-2024: 2.15, d=−0.09). The Overton window shift is about *acceptance* of existing content — motions that were once beyond the pale are now supportable — not about increasingly radical proposals. |
||||
|
||||
 |
||||
|
||||
### LLM scoring reliability |
||||
|
||||
A stratified manual audit of 20 motions (5 per extremity bucket) achieved **75% agreement** (15/20), above the 70% threshold but borderline. Identified biases: |
||||
|
||||
- **Anti-institutional overrating:** LLM inflates scores on anti-EU and anti-government motions (procedural stances scored as radical policy) |
||||
- **Migration/cultural adjacency inflation:** Motions mentioning migration-adjacent topics score higher than warranted |
||||
- **Climate topic inflation:** Technical environmental motions scored higher than warranted |
||||
|
||||
The LLM conflates *stylistic extremity* (inflammatory keywords, charged topics) with *material impact* (substantive rights restrictions, institutional change). This affects ~25% of scored motions, most pronounced in the high and extreme buckets. |
||||
|
||||
**Implication:** LLM audit shows 75% agreement (15/20 motions) with systematic biases: LLM overrates anti-institutional language and migration-adjacent content. A flat trend may partially reflect these biases rather than genuine content stability. See two-dimensional rescoring (deferred). |
||||
|
||||
--- |
||||
|
||||
## 4. Indicator 3: SVD Spatial Drift — Acceptance Without Conversion |
||||
|
||||
### Methodology: Procrustes-aligned PCA |
||||
|
||||
Raw per-window SVD axes re-orient independently each year, causing 9/10 consecutive window pairs to fail axis stability (Spearman ρ < 0.7). To enable cross-window comparison, we use the same alignment pipeline as the Explorer UI compass: |
||||
|
||||
1. Zero-pad party vectors to max dimension across all windows |
||||
2. Chain Procrustes orthogonal rotation (each window to the previous aligned one) to preserve relative structure |
||||
3. Global PCA on the stacked aligned matrix for a common 2D reference frame |
||||
4. Flip-correction per component using canonical left/right parties |
||||
|
||||
This ensures all positions live in the same coordinate system — positional changes reflect genuine voting behavior shifts, not axis re-orientation artifacts. |
||||
|
||||
### Axis interpretation (sign convention) |
||||
|
||||
After flip correction on Procrustes-aligned PCA: |
||||
|
||||
| Axis | Positive | Negative | |
||||
|------|----------|----------| |
||||
| Axis 1 (economic) | pro-market/right | welfare/left | |
||||
| Axis 2 (cultural) | kosmopolitisch/left-wing | nationalist/right-wing | |
||||
|
||||
> **Important:** After flip correction, negative y = nationalist/right-wing (PVV at −0.56, FVD at −0.36). Positive y = kosmopolitisch/left-wing (Volt at +0.27, GL-PvdA at +0.21). This is the *opposite* of what the raw `SVD_THEMES[2]` label says, because PCA axes are flip-corrected to align with canonical left/right parties. SVD labels reflect voting patterns, not semantic content. |
||||
|
||||
### Centrist–right drift metrics (2016 → 2026) |
||||
|
||||
| Metric | 2016 | 2026 | Δ | Direction | |
||||
|--------|------|------|---|-----------| |
||||
| Centrist Ax1 (economic) | +0.340 | +0.117 | −0.223 | LEFT (more welfare) | |
||||
| Centrist Ax2 (cultural) | +0.010 | +0.091 | +0.081 | LEFT (more kosmopolitisch) | |
||||
| Right Ax2 (cultural) | −0.272 | −0.337 | −0.065 | RIGHT (more nationalist) | |
||||
| Cultural gap (\|C−R\|) | 0.282 | 0.428 | +0.146 | DIVERGENCE | |
||||
|
||||
 |
||||
|
||||
### Central tension: Acceptance without conversion |
||||
|
||||
Centrist voting support for right-wing motions surged (d=+0.85 opposition-only), yet Procrustes-aligned SVD analysis shows: |
||||
|
||||
- Centrists moved **LEFT on both axes** (more welfare-oriented, more kosmopolitisch) |
||||
- Right-wing parties moved **further RIGHT culturally** (more nationalist) |
||||
- The centrist–right cultural distance **widened** from 0.282 to 0.428 (+0.146) |
||||
|
||||
This pattern — greater political support combined with greater ideological distance — resolves as **"acceptance without conversion."** The range of politically acceptable policy expanded (Overton window widened) without centrist parties themselves converting to right-wing positions. Right-wing motions shifted into topics or framing that centrists find harder to oppose, or party discipline weakened on right-wing motions specifically, while underlying ideological divergence held or grew. |
||||
|
||||
--- |
||||
|
||||
## 5. Synthesis |
||||
|
||||
### Tier 1 — Converging evidence (strong) |
||||
|
||||
1. **Centrist support surged post-2024:** Cohen's d = +0.68 overall, d = +0.85 for opposition-only motions. Not a coalition artifact. |
||||
2. **Migration is the primary domain:** Migration motions gained +0.233 in centrist support vs. +0.076 for non-migration. Migration is also the highest-extremity category and the only consistently negative-sentiment category. |
||||
3. **Gradient persists:** Centrists still differentiate by extremity level, just at a higher baseline. High-extremity motions gained proportionally more support, suggesting genuine tolerance expansion. |
||||
|
||||
### Tier 2 — Tension (explanatory, not contradictory) |
||||
|
||||
**Acceptance without conversion.** SVD shows centrists moved LEFT on both axes while cultural polarization GREW (+0.146). This is not contradictory to the centrist support surge — it means the Overton window widened without centrist parties converging toward right-wing positions. Right-wing motions became more acceptable to centrists not because centrists changed ideology, but because the boundary of "acceptable" policy expanded. Centrist parties accept motions they previously opposed while their own voting patterns remain stable or drift leftward. |
||||
|
||||
**Mechanism analysis** of the 24 right-wing motions with highest centrist support post-2024 reveals HOW right-wing motions gain centrist backing without ideological conversion: |
||||
|
||||
| Mechanism | Count | % | |
||||
|-----------|-------|---| |
||||
| Consensus framing (shared values: safety, efficiency, pragmatism) | 8 | 33% | |
||||
| Institutional/rule-of-law (oversight, transparency, anti-corruption) | 5 | 21% | |
||||
| Welfare/service expansion (protect vulnerable groups) | 4 | 17% | |
||||
| Procedural/technical | 3 | 13% | |
||||
| Local/constituency | 1 | 4% | |
||||
| Coalition alignment | 1 | 4% | |
||||
| Symbolic/declaratory | 1 | 4% | |
||||
| Targeted restriction | 1 | 4% | |
||||
| System dismantling | 0 | 0% | |
||||
| Crisis response | 0 | 0% | |
||||
|
||||
**Key finding:** The dominant pathway is *consensus framing* — right-wing motions package their requests in widely-shared values (public safety, economic competitiveness, energy transition pragmatism) stripping away partisan markers. Institutional/rule-of-law framing comes second: motions strengthening oversight or legal frameworks make centrist opposition untenable since these parties stake their identity on good governance. Critically, only 1 motion involves targeted rights restriction and **zero involve system dismantling** — the truly ideological right-wing agenda (asylum stops, treaty exits, fundamental institutional upheaval) does not gain centrist support. Right-wing influence flows not through conversion but through repackaging: speaking in vocabulary centrists already accept. |
||||
|
||||
### Tier 3 — Weak/noisy (updated with 2D findings) |
||||
|
||||
Content extremity trend is flat (d=−0.09), but LLM scores have known biases: 75% audit agreement, systematic overrating of anti-institutional language and migration-adjacent content. **Two-dimensional rescoring completed (n=117, stratified):** Pearson r=0.45 between stylistic extremity and material impact — moderate, confirming the dimensions are separable. Material impact averages 0.85 points above stylistic (2.86 vs. 2.01), with 36.8% of motions using restrained language to mask high-impact policies. The original single-score LLM conflated inflammatory phrasing with substantive policy effect, explaining ~25% of the audit disagreement. A flat single-dimension trend may reflect this conflation rather than genuine content stability. |
||||
|
||||
### Uncertainty hierarchy |
||||
|
||||
| Evidence Level | Indicator | Status | |
||||
|---------------|-----------|--------| |
||||
| **Strong** | Centrist support surge (opposition-controlled) | Confirmed | |
||||
| **Strong** | Spatial divergence — acceptance without conversion | Confirmed | |
||||
| **Moderate** | Migration-specificity of the shift | Confirmed | |
||||
| **Inconclusive** | Extremity-stratified tolerance shift | Gradient persists, baseline-shifted | |
||||
| **Weak** | Content extremity trend | No increase (LLM biases, 75% audit; 2D rescoring r=0.45) | |
||||
|
||||
--- |
||||
|
||||
## 6. Limitations |
||||
|
||||
- **Small-N time series:** 8 pre-2024 years, 3 post-2024 years (2026 is partial). Effect sizes are descriptive, not confirmatory. |
||||
- **LLM extremity scores:** 75% audit agreement; borderline. Two-dimensional rescoring confirms stylistic and material dimensions are separable (r=0.45). The original single score conflates language radicalism with policy impact. |
||||
- **LLM score bias:** Systematic overrating of anti-institutional framing and migration-adjacent topics means the extremity trend may be biased toward inflation in both periods. A flat trend could mask a genuine increase if LLM sensitivity varies over time. |
||||
- **Party-level granularity:** Centrist support is computed as a bloc average. Individual party trajectories (e.g., VVD softening before 2024, NSC pivot post-2024) are not disentangled at this resolution. |
||||
- **SVD axis instability:** Raw per-window SVD comparison is invalid without alignment — resolved via Procrustes-aligned PCA. Spatial divergence conclusion depends on this alignment. |
||||
- **Coalition composition:** Hardcoded per year. 2024 is ambiguous (Rutte IV until July, Schoof thereafter). Early 2024 motions may be miscoded. |
||||
- **Submitter party identification:** Parsed from motion title prefixes. ~10% of motions have non-standard titles (bills, amendments) and are excluded from opposition-only analysis. |
||||
- **Pass rate baseline:** Computed across motions with recorded votes. Unanimous consent motions are excluded, potentially biasing baseline upward. Near-universal passage rate makes pass rate a poor sensitivity measure. |
||||
|
||||
--- |
||||
|
||||
## 7. Figures |
||||
|
||||
1. `breakpoint_figure_1.png` — Centrist support over time (all RW, opposition-only, migration, non-migration, + baseline) |
||||
2. `breakpoint_figure_2.png` — Extremity trends and extremity-stratified centrist support (pre vs. post 2024) |
||||
3. `svd_drift_chart.png` — Procrustes-aligned centrist center trajectory (see Section 4) |
||||
|
||||
--- |
||||
|
||||
## 8. Next Steps |
||||
|
||||
1. **Two-dimensional extremity rescoring:** **IN PROGRESS.** Stratified sample (n=117) scored with dual-dimension prompt via subagent dispatches. Pearson r=0.45 — dimensions are separable. Material impact averages 0.85 points above stylistic. Next: rescore the remaining ~2,870 motions at higher batch size to enable 2D extremity-stratified analyses, or re-run the full pipeline if correlation sufficient to recalibrate. |
||||
|
||||
2. **Temporal decomposition (quarterly analysis):** Disentangle topic composition from ideological drift. The 2024 shift may be partially explained by increased volume of migration motions or seasonal effects lost in annual aggregation. |
||||
|
||||
3. **Mechanism analysis:** **DONE.** 24 post-2024 high-centrist-support motions classified across 10 mechanism types. Dominant pathways: consensus framing (33%), institutional/rule-of-law (21%), welfare/service expansion (17%). Targeted restrictions and system dismantling near zero — right-wing gains centrist support by repackaging in centrist-friendly language, not by converting centrists. |
||||
|
Before Width: | Height: | Size: 278 KiB After Width: | Height: | Size: 225 KiB |
@ -0,0 +1,555 @@ |
||||
<!DOCTYPE html> |
||||
<html lang="en"> |
||||
<head> |
||||
<meta charset="UTF-8"> |
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||
<title>Overton Window Analysis | Dutch Parliament 2016-2026</title> |
||||
<style> |
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } |
||||
body { |
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; |
||||
color: #1a1a2e; |
||||
background: #f8f9fa; |
||||
line-height: 1.6; |
||||
font-size: 15px; |
||||
} |
||||
.container { max-width: 900px; margin: 0 auto; padding: 40px 24px; background: #fff; } |
||||
|
||||
/* Header */ |
||||
.header { text-align: center; padding: 48px 0 32px; border-bottom: 2px solid #e8ecef; margin-bottom: 40px; } |
||||
.header h1 { font-size: 28px; font-weight: 700; color: #1a1a2e; letter-spacing: -0.3px; } |
||||
.header .subtitle { font-size: 16px; color: #5a6a7a; margin-top: 8px; max-width: 600px; margin-left: auto; margin-right: auto; } |
||||
.header .meta { font-size: 12px; color: #8a9aa8; margin-top: 12px; letter-spacing: 0.5px; text-transform: uppercase; } |
||||
|
||||
/* Section headings */ |
||||
h2 { font-size: 20px; font-weight: 600; color: #1a1a2e; margin: 44px 0 16px; padding-bottom: 8px; border-bottom: 1px solid #e8ecef; } |
||||
h3 { font-size: 16px; font-weight: 600; color: #1a1a2e; margin: 24px 0 12px; } |
||||
|
||||
/* Body text */ |
||||
p { margin-bottom: 14px; color: #2d3a45; font-size: 14.5px; } |
||||
.text-small { font-size: 12.5px; color: #6a7a8a; } |
||||
|
||||
/* Bar charts */ |
||||
.bar-group { margin: 16px 0; padding: 8px 0; } |
||||
.bar-label { display: flex; justify-content: space-between; font-size: 12.5px; color: #4a5a6a; margin-bottom: 4px; } |
||||
.bar-label .n-count { color: #8a9aa8; font-size: 11.5px; } |
||||
.bar-track { height: 24px; background: #e8ecef; border-radius: 4px; overflow: hidden; margin-bottom: 2px; position: relative; } |
||||
.bar-track + .bar-track { margin-top: 2px; } |
||||
.bar-fill { height: 100%; border-radius: 4px; transition: width 0.3s; } |
||||
.bar-fill.pre { background: #8a9aa8; } |
||||
.bar-fill.post { background: #2563eb; } |
||||
.bar-legend { display: flex; gap: 20px; font-size: 12px; color: #4a5a6a; margin: 4px 0 16px; } |
||||
.bar-legend span { display: flex; align-items: center; gap: 6px; } |
||||
.bar-legend .swatch { display: inline-block; width: 14px; height: 14px; border-radius: 3px; } |
||||
.bar-legend .swatch.pre { background: #8a9aa8; } |
||||
.bar-legend .swatch.post { background: #2563eb; } |
||||
|
||||
/* Section 4 and 5 charts */ |
||||
.chart-block { margin-bottom: 32px; background: #f8f9fa; border-radius: 8px; padding: 20px; } |
||||
|
||||
/* Note box */ |
||||
.note { background: #f0f4f8; border-left: 3px solid #2563eb; padding: 12px 16px; margin: 16px 0; font-size: 13px; color: #3a4a5a; border-radius: 0 6px 6px 0; } |
||||
|
||||
/* Example motion boxes */ |
||||
.motion-box { background: #f8f9fa; border-radius: 8px; padding: 20px; margin: 16px 0; border: 1px solid #e8ecef; } |
||||
.motion-box h3 { margin-top: 0; font-size: 15px; } |
||||
.motion-box .motion-id { font-size: 11.5px; color: #8a9aa8; text-transform: uppercase; letter-spacing: 0.5px; } |
||||
.motion-box .motion-title { font-style: italic; color: #2d3a45; margin: 4px 0 10px; font-size: 13.5px; } |
||||
.motion-stats { display: flex; flex-wrap: wrap; gap: 16px; margin: 10px 0; } |
||||
.motion-stat { background: #fff; border-radius: 6px; padding: 8px 14px; border: 1px solid #e8ecef; flex: 1; min-width: 100px; } |
||||
.motion-stat .stat-label { font-size: 11px; color: #8a9aa8; text-transform: uppercase; letter-spacing: 0.3px; } |
||||
.motion-stat .stat-value { font-size: 18px; font-weight: 600; color: #1a1a2e; margin-top: 2px; } |
||||
.motion-box p { font-size: 13.5px; color: #4a5a6a; margin-bottom: 0; } |
||||
|
||||
/* Yearly table */ |
||||
.data-table { width: 100%; border-collapse: collapse; font-size: 13px; margin: 12px 0; } |
||||
.data-table th { text-align: left; padding: 8px 12px; background: #f0f4f8; color: #3a4a5a; font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: 0.3px; border-bottom: 2px solid #d0d8e0; } |
||||
.data-table td { padding: 8px 12px; border-bottom: 1px solid #e8ecef; color: #2d3a45; } |
||||
.data-table tr:hover td { background: #f8f9fa; } |
||||
.data-table .num { text-align: right; font-variant-numeric: tabular-nums; } |
||||
.data-table .change-up { color: #2563eb; } |
||||
.data-table .change-down { color: #8a9aa8; } |
||||
|
||||
/* Sparkline bars */ |
||||
.sparkline { display: flex; align-items: flex-end; gap: 4px; height: 120px; padding: 16px 0; } |
||||
.spark-col { display: flex; flex-direction: column; align-items: center; flex: 1; } |
||||
.spark-bar { width: 100%; max-width: 50px; min-height: 4px; background: #2563eb; border-radius: 3px 3px 0 0; transition: height 0.3s; } |
||||
.spark-bar.low-n { background: #b0c0d0; } |
||||
.spark-label { font-size: 10.5px; color: #6a7a8a; margin-top: 6px; text-align: center; } |
||||
|
||||
/* Side-by-side right wing section */ |
||||
.two-col { display: flex; gap: 24px; margin: 16px 0; } |
||||
.two-col > div { flex: 1; background: #f8f9fa; border-radius: 8px; padding: 20px; } |
||||
.two-col h3 { margin-top: 0; font-size: 15px; } |
||||
.big-stat { font-size: 36px; font-weight: 700; color: #1a1a2e; line-height: 1.1; margin: 8px 0; } |
||||
.big-stat .sub { font-size: 14px; font-weight: 400; color: #6a7a8a; } |
||||
.pre-post-row { display: flex; justify-content: space-between; margin: 4px 0; font-size: 13px; color: #4a5a6a; } |
||||
|
||||
/* Heatmap */ |
||||
.heatmap { width: 100%; border-collapse: collapse; font-size: 13px; margin: 12px 0; } |
||||
.heatmap th { padding: 8px 12px; background: #f0f4f8; color: #3a4a5a; font-weight: 600; font-size: 12px; text-align: center; border-bottom: 2px solid #d0d8e0; } |
||||
.heatmap th:first-child { text-align: left; } |
||||
.heatmap td { padding: 10px 12px; text-align: center; border-bottom: 1px solid #e8ecef; font-variant-numeric: tabular-nums; } |
||||
.heatmap td:first-child { text-align: left; font-weight: 500; color: #3a4a5a; } |
||||
.heatmap .cell-val { font-weight: 600; font-size: 14px; } |
||||
.heatmap .cell-pct { font-size: 11px; color: #8a9aa8; } |
||||
.heatmap .highlight { background: #2563eb; color: #fff; border-radius: 4px; } |
||||
.heatmap .highlight .cell-pct { color: rgba(255,255,255,0.75); } |
||||
.heatmap .highlight .cell-val { color: #fff; } |
||||
.hm-mid { background: #eef4fc; } |
||||
.hm-low { background: #f8f9fa; } |
||||
|
||||
/* Takeaways */ |
||||
.verdict { background: #f0f4f8; border-radius: 8px; padding: 24px; margin: 16px 0; border: 1px solid #e0e8f0; } |
||||
.verdict ul { list-style: none; padding: 0; } |
||||
.verdict li { padding: 8px 0; border-bottom: 1px solid #e0e8f0; font-size: 14px; color: #2d3a45; } |
||||
.verdict li:last-child { border-bottom: none; } |
||||
.verdict li strong { color: #1a1a2e; } |
||||
|
||||
/* Footer */ |
||||
.footer { text-align: center; padding: 32px 0 16px; border-top: 1px solid #e8ecef; margin-top: 40px; font-size: 12px; color: #8a9aa8; } |
||||
|
||||
/* Responsive */ |
||||
@media (max-width: 640px) { |
||||
.container { padding: 20px 16px; } |
||||
.two-col { flex-direction: column; } |
||||
.motion-stats { flex-direction: column; } |
||||
.header h1 { font-size: 22px; } |
||||
.spark-label { font-size: 9px; } |
||||
} |
||||
</style> |
||||
</head> |
||||
<body> |
||||
<div class="container"> |
||||
|
||||
<!-- 1. Header --> |
||||
<div class="header"> |
||||
<h1>The Overton Window in the Dutch Parliament</h1> |
||||
<div class="subtitle">A gravity-model analysis of 29,591 parliamentary motions from 2016 to 2026 reveals how the window of acceptable policy debate shifted after the 2024 Schoof cabinet formation.</div> |
||||
<div class="meta">Analysis based on voting records · Data: motions.db · Period: 2016–2026</div> |
||||
</div> |
||||
|
||||
<!-- 2. What Is the Overton Window --> |
||||
<h2>What Is the Overton Window</h2> |
||||
<p>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.</p> |
||||
<p>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.</p> |
||||
|
||||
<!-- 3. Methodology --> |
||||
<h2>Methodology</h2> |
||||
<p>Each motion was scored on two independent dimensions by an LLM-based classifier: <strong>stijl</strong> (rhetorical extremity, 1–5) and <strong>materieel</strong> (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 <strong>canonical definition</strong> uses 4 strict centrist parties: D66, CDA, CU, and NSC. A broader <strong>all-party</strong> model (including VVD, BBB, and other non-extreme parties) is shown for comparison.</p> |
||||
<p>The dataset contains <strong>29,591</strong> 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: <strong>1.36</strong> (on a 1–5 scale). Mean material extremity: <strong>2.12</strong>. The Pearson correlation between the two dimensions is <strong>r = 0.43</strong>, confirming that style and substance are separable but moderately related.</p> |
||||
|
||||
<!-- 4. Gravity-Controlled Analysis --> |
||||
<h2>Gravity-Controlled Analysis</h2> |
||||
<p>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.</p> |
||||
|
||||
<div class="chart-block"> |
||||
<div class="bar-legend"> |
||||
<span><span class="swatch pre"></span> Pre-2024</span> |
||||
<span><span class="swatch post"></span> Post-2024</span> |
||||
</div> |
||||
|
||||
<div class="bar-group"> |
||||
<div class="bar-label"> |
||||
<span>M1 — Lowest extremity</span> |
||||
<span class="n-count">Pre: 4,495 · Post: 2,068</span> |
||||
</div> |
||||
<div class="bar-track"><div class="bar-fill pre" style="width: 71.5%"></div></div> |
||||
<div class="bar-label" style="margin-top:1px"><span></span><span>Pre: 0.715 / Post: 0.655</span></div> |
||||
<div class="bar-track"><div class="bar-fill post" style="width: 65.5%"></div></div> |
||||
</div> |
||||
|
||||
<div class="bar-group"> |
||||
<div class="bar-label"> |
||||
<span>M2</span> |
||||
<span class="n-count">Pre: 10,979 · Post: 3,698</span> |
||||
</div> |
||||
<div class="bar-track"><div class="bar-fill pre" style="width: 61.4%"></div></div> |
||||
<div class="bar-label" style="margin-top:1px"><span></span><span>Pre: 0.614 / Post: 0.632</span></div> |
||||
<div class="bar-track"><div class="bar-fill post" style="width: 63.2%"></div></div> |
||||
</div> |
||||
|
||||
<div class="bar-group"> |
||||
<div class="bar-label"> |
||||
<span>M3</span> |
||||
<span class="n-count">Pre: 4,984 · Post: 1,730</span> |
||||
</div> |
||||
<div class="bar-track"><div class="bar-fill pre" style="width: 42.3%"></div></div> |
||||
<div class="bar-label" style="margin-top:1px"><span></span><span>Pre: 0.423 / Post: 0.449</span></div> |
||||
<div class="bar-track"><div class="bar-fill post" style="width: 44.9%"></div></div> |
||||
</div> |
||||
|
||||
<div class="bar-group"> |
||||
<div class="bar-label"> |
||||
<span>M4</span> |
||||
<span class="n-count">Pre: 1,076 · Post: 346</span> |
||||
</div> |
||||
<div class="bar-track"><div class="bar-fill pre" style="width: 26.7%"></div></div> |
||||
<div class="bar-label" style="margin-top:1px"><span></span><span>Pre: 0.267 / Post: 0.278</span></div> |
||||
<div class="bar-track"><div class="bar-fill post" style="width: 27.8%"></div></div> |
||||
</div> |
||||
|
||||
<div class="bar-group"> |
||||
<div class="bar-label"> |
||||
<span>M5 — Highest extremity</span> |
||||
<span class="n-count">Pre: 161 · Post: 33</span> |
||||
</div> |
||||
<div class="bar-track"><div class="bar-fill pre" style="width: 13.8%"></div></div> |
||||
<div class="bar-label" style="margin-top:1px"><span></span><span>Pre: 0.138 / Post: 0.229</span></div> |
||||
<div class="bar-track"><div class="bar-fill post" style="width: 22.9%"></div></div> |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="note"> |
||||
<strong>Interpretation.</strong> The gravity ranking is sensible: higher gravity levels consistently show lower centrist support. Post-2024, centrist support increased at levels M3 through M5, with the largest relative gain at M5 (from 0.138 to 0.229). At M1, centrist support actually decreased slightly post-2024, suggesting that the window shift primarily affected more extreme motions rather than broadening consensus on low-extremity proposals. |
||||
</div> |
||||
|
||||
<!-- 5. Strict 4-Party Centrist Model --> |
||||
<h2>Strict 4-Party Centrist Model</h2> |
||||
<p>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.</p> |
||||
|
||||
<div class="chart-block"> |
||||
<div class="bar-legend"> |
||||
<span><span class="swatch pre"></span> Pre-2024</span> |
||||
<span><span class="swatch post"></span> Post-2024</span> |
||||
</div> |
||||
|
||||
<div class="bar-group"> |
||||
<div class="bar-label"> |
||||
<span>M1 — Lowest extremity</span> |
||||
<span class="n-count">Pre: 4,495 · Post: 2,068</span> |
||||
</div> |
||||
<div class="bar-track"><div class="bar-fill pre" style="width: 71.8%"></div></div> |
||||
<div class="bar-label" style="margin-top:1px"><span></span><span>Pre: 0.718 / Post: 0.647</span></div> |
||||
<div class="bar-track"><div class="bar-fill post" style="width: 64.7%"></div></div> |
||||
</div> |
||||
|
||||
<div class="bar-group"> |
||||
<div class="bar-label"> |
||||
<span>M2</span> |
||||
<span class="n-count">Pre: 10,979 · Post: 3,698</span> |
||||
</div> |
||||
<div class="bar-track"><div class="bar-fill pre" style="width: 60.8%"></div></div> |
||||
<div class="bar-label" style="margin-top:1px"><span></span><span>Pre: 0.608 / Post: 0.659</span></div> |
||||
<div class="bar-track"><div class="bar-fill post" style="width: 65.9%"></div></div> |
||||
</div> |
||||
|
||||
<div class="bar-group"> |
||||
<div class="bar-label"> |
||||
<span>M3</span> |
||||
<span class="n-count">Pre: 4,984 · Post: 1,730</span> |
||||
</div> |
||||
<div class="bar-track"><div class="bar-fill pre" style="width: 39.1%"></div></div> |
||||
<div class="bar-label" style="margin-top:1px"><span></span><span>Pre: 0.391 / Post: 0.475</span></div> |
||||
<div class="bar-track"><div class="bar-fill post" style="width: 47.5%"></div></div> |
||||
</div> |
||||
|
||||
<div class="bar-group"> |
||||
<div class="bar-label"> |
||||
<span>M4</span> |
||||
<span class="n-count">Pre: 1,076 · Post: 346</span> |
||||
</div> |
||||
<div class="bar-track"><div class="bar-fill pre" style="width: 18.9%"></div></div> |
||||
<div class="bar-label" style="margin-top:1px"><span></span><span>Pre: 0.189 / Post: 0.238</span></div> |
||||
<div class="bar-track"><div class="bar-fill post" style="width: 23.8%"></div></div> |
||||
</div> |
||||
|
||||
<div class="bar-group"> |
||||
<div class="bar-label"> |
||||
<span>M5 — Highest extremity</span> |
||||
<span class="n-count">Pre: 161 · Post: 33</span> |
||||
</div> |
||||
<div class="bar-track"><div class="bar-fill pre" style="width: 4.4%"></div></div> |
||||
<div class="bar-label" style="margin-top:1px"><span></span><span>Pre: 0.044 / Post: 0.101</span></div> |
||||
<div class="bar-track"><div class="bar-fill post" style="width: 10.1%"></div></div> |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="note"> |
||||
<strong>Interpretation.</strong> Under the strict 4-party model, cs_strict values are indeed lower than the all-party figures. At M5, the centrist core went from cs_strict = 0.044 pre-2024 to 0.101 post-2024 — a 2.3x relative increase but still very low in absolute terms. This suggests that while the strict centrist core became more tolerant of extreme motions after the 2024 watershed, the absolute level of acceptance remains modest. The shift is real but not transformative for the most extreme proposals. |
||||
</div> |
||||
|
||||
<!-- 6. Example Motions --> |
||||
<h2>Example Motions</h2> |
||||
<p>Three motions illustrate different patterns in the Overton window shift.</p> |
||||
|
||||
<div class="motion-box"> |
||||
<div class="motion-id">Motion 144 · Hidden Impact</div> |
||||
<div class="motion-title">Motie van het lid Eerdmans over zich inzetten voor juridische en politieke ruimte om asielprocedures buiten de EU te kunnen afhandelen</div> |
||||
<p>This motion proposes external processing of asylum procedures outside the EU. It scores <strong>stijl = 1</strong> (neutral legal language, no rhetorical escalation) but <strong>materieel = 4</strong> (fundamental policy reform). The modest stylistic framing masks the substantive ambition. The strict centrist support (cs_strict) rose from <strong>0.00</strong> pre-2024 to <strong>1.00</strong> post-2024 — a motion that no centrist party would touch before the Schoof cabinet became universally acceptable after.</p> |
||||
<div class="motion-stats"> |
||||
<div class="motion-stat"><div class="stat-label">Stijl</div><div class="stat-value">1</div></div> |
||||
<div class="motion-stat"><div class="stat-label">Materieel</div><div class="stat-value">4</div></div> |
||||
<div class="motion-stat"><div class="stat-label">CS strict pre</div><div class="stat-value">0.00</div></div> |
||||
<div class="motion-stat"><div class="stat-label">CS strict post</div><div class="stat-value">1.00</div></div> |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="motion-box"> |
||||
<div class="motion-id">Motion 28109 · The Line</div> |
||||
<div class="motion-title">Motie van de leden Van Haga en Smolders over het Vluchtelingenverdrag uit 1951 opzeggen</div> |
||||
<p>This motion calls for withdrawing from the 1951 Refugee Convention. It scores <strong>stijl = 5</strong> and <strong>materieel = 5</strong> — maximum extremity on both dimensions. Despite the broader post-2024 shift in the Overton window, strict centrist support remained at <strong>0.00</strong> both before and after the watershed. Some positions remain outside the window of acceptability regardless of the political climate.</p> |
||||
<div class="motion-stats"> |
||||
<div class="motion-stat"><div class="stat-label">Stijl</div><div class="stat-value">5</div></div> |
||||
<div class="motion-stat"><div class="stat-label">Materieel</div><div class="stat-value">5</div></div> |
||||
<div class="motion-stat"><div class="stat-label">CS strict pre</div><div class="stat-value">0.00</div></div> |
||||
<div class="motion-stat"><div class="stat-label">CS strict post</div><div class="stat-value">0.00</div></div> |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="motion-box"> |
||||
<div class="motion-id">Motion 306 · The Shift</div> |
||||
<div class="motion-title">Motie van de leden Boomsma en Van Zanten over maatregelen voor de vrijwillige terugkeer van Syriërs</div> |
||||
<p>This motion proposes measures for voluntary return of Syrians. It scores <strong>stijl = 2</strong> and <strong>materieel = 3</strong> — moderate on both dimensions. Strict centrist support went from <strong>0.00</strong> pre-2024 to <strong>1.00</strong> 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.</p> |
||||
<div class="motion-stats"> |
||||
<div class="motion-stat"><div class="stat-label">Stijl</div><div class="stat-value">2</div></div> |
||||
<div class="motion-stat"><div class="stat-label">Materieel</div><div class="stat-value">3</div></div> |
||||
<div class="motion-stat"><div class="stat-label">CS strict pre</div><div class="stat-value">0.00</div></div> |
||||
<div class="motion-stat"><div class="stat-label">CS strict post</div><div class="stat-value">1.00</div></div> |
||||
</div> |
||||
</div> |
||||
|
||||
<!-- 7. Yearly Trend -- M3+ Motions --> |
||||
<h2>Yearly Trend — M3+ Strict Centrist Support</h2> |
||||
<p>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.</p> |
||||
|
||||
<div class="chart-block"> |
||||
<div class="sparkline"> |
||||
<div class="spark-col"> |
||||
<div class="spark-bar low-n" style="height: 70.5px"></div> |
||||
<div class="spark-label">2016</div> |
||||
</div> |
||||
<div class="spark-col"> |
||||
<div class="spark-bar low-n" style="height: 66.4px"></div> |
||||
<div class="spark-label">2017</div> |
||||
</div> |
||||
<div class="spark-col"> |
||||
<div class="spark-bar low-n" style="height: 95.8px"></div> |
||||
<div class="spark-label">2018</div> |
||||
</div> |
||||
<div class="spark-col"> |
||||
<div class="spark-bar" style="height: 33.6px"></div> |
||||
<div class="spark-label">2019</div> |
||||
</div> |
||||
<div class="spark-col"> |
||||
<div class="spark-bar" style="height: 30.8px"></div> |
||||
<div class="spark-label">2020</div> |
||||
</div> |
||||
<div class="spark-col"> |
||||
<div class="spark-bar" style="height: 32.9px"></div> |
||||
<div class="spark-label">2021</div> |
||||
</div> |
||||
<div class="spark-col"> |
||||
<div class="spark-bar" style="height: 31.3px"></div> |
||||
<div class="spark-label">2022</div> |
||||
</div> |
||||
<div class="spark-col"> |
||||
<div class="spark-bar" style="height: 35.5px"></div> |
||||
<div class="spark-label">2023</div> |
||||
</div> |
||||
<div class="spark-col"> |
||||
<div class="spark-bar" style="height: 48.7px"></div> |
||||
<div class="spark-label">2024</div> |
||||
</div> |
||||
<div class="spark-col"> |
||||
<div class="spark-bar" style="height: 45.1px"></div> |
||||
<div class="spark-label">2025</div> |
||||
</div> |
||||
<div class="spark-col"> |
||||
<div class="spark-bar" style="height: 29.7px"></div> |
||||
<div class="spark-label">2026</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
|
||||
<table class="data-table"> |
||||
<thead> |
||||
<tr> |
||||
<th>Year</th> |
||||
<th class="num">Count (M3+)</th> |
||||
<th class="num">CS strict</th> |
||||
<th class="num">Change</th> |
||||
</tr> |
||||
</thead> |
||||
<tbody> |
||||
<tr> |
||||
<td>2016</td> |
||||
<td class="num">17</td> |
||||
<td class="num">0.705</td> |
||||
<td class="num">—</td> |
||||
</tr> |
||||
<tr> |
||||
<td>2017</td> |
||||
<td class="num">9</td> |
||||
<td class="num">0.664</td> |
||||
<td class="num change-down">-0.041</td> |
||||
</tr> |
||||
<tr> |
||||
<td>2018</td> |
||||
<td class="num">12</td> |
||||
<td class="num">0.958</td> |
||||
<td class="num change-up">+0.294</td> |
||||
</tr> |
||||
<tr> |
||||
<td>2019</td> |
||||
<td class="num">851</td> |
||||
<td class="num">0.336</td> |
||||
<td class="num change-down">-0.622</td> |
||||
</tr> |
||||
<tr> |
||||
<td>2020</td> |
||||
<td class="num">1,157</td> |
||||
<td class="num">0.308</td> |
||||
<td class="num change-down">-0.028</td> |
||||
</tr> |
||||
<tr> |
||||
<td>2021</td> |
||||
<td class="num">1,229</td> |
||||
<td class="num">0.329</td> |
||||
<td class="num change-up">+0.021</td> |
||||
</tr> |
||||
<tr> |
||||
<td>2022</td> |
||||
<td class="num">1,199</td> |
||||
<td class="num">0.313</td> |
||||
<td class="num change-down">-0.016</td> |
||||
</tr> |
||||
<tr> |
||||
<td>2023</td> |
||||
<td class="num">1,099</td> |
||||
<td class="num">0.355</td> |
||||
<td class="num change-up">+0.042</td> |
||||
</tr> |
||||
<tr> |
||||
<td>2024</td> |
||||
<td class="num">1,252</td> |
||||
<td class="num">0.487</td> |
||||
<td class="num change-up">+0.132</td> |
||||
</tr> |
||||
<tr> |
||||
<td>2025</td> |
||||
<td class="num">1,101</td> |
||||
<td class="num">0.451</td> |
||||
<td class="num change-down">-0.036</td> |
||||
</tr> |
||||
<tr> |
||||
<td>2026</td> |
||||
<td class="num">404</td> |
||||
<td class="num">0.297</td> |
||||
<td class="num change-down">-0.154</td> |
||||
</tr> |
||||
</tbody> |
||||
</table> |
||||
|
||||
<div class="note"> |
||||
<strong>Note.</strong> The spike in 2018 (cs_strict = 0.958) is based on only 12 motions and should not be interpreted as a genuine shift in the Overton window. The low-N period from 2016 to 2018 reflects limited availability of digital motion records. The post-2024 peak (0.487 in 2024) followed by a decline through 2025–2026 suggests the shift may have been concentrated around the immediate Schoof cabinet formation period. |
||||
</div> |
||||
|
||||
<!-- 8. Right-Wing vs Other --> |
||||
<h2>Right-Wing vs Other Parties</h2> |
||||
<p>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.</p> |
||||
|
||||
<div class="two-col"> |
||||
<div> |
||||
<h3>Right-Wing Motions</h3> |
||||
<div class="big-stat">0.384 <span class="sub">→ 0.620</span></div> |
||||
<div class="pre-post-row"><span>Pre-2024</span><span>0.384</span></div> |
||||
<div class="pre-post-row"><span>Post-2024</span><span>0.620</span></div> |
||||
<div class="pre-post-row" style="margin-top:6px;font-weight:600;color:#2563eb;"><span>Change</span><span>+0.236</span></div> |
||||
<p class="text-small" style="margin-top:8px;">N: 1,911 pre · 1,119 post</p> |
||||
</div> |
||||
<div> |
||||
<h3>Other Party Motions</h3> |
||||
<div class="big-stat">0.587 <span class="sub">→ 0.581</span></div> |
||||
<div class="pre-post-row"><span>Pre-2024</span><span>0.587</span></div> |
||||
<div class="pre-post-row"><span>Post-2024</span><span>0.581</span></div> |
||||
<div class="pre-post-row" style="margin-top:6px;font-weight:600;color:#8a9aa8;"><span>Change</span><span>-0.006</span></div> |
||||
<p class="text-small" style="margin-top:8px;">N: 17,768 pre · 8,772 post</p> |
||||
</div> |
||||
</div> |
||||
|
||||
<p>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.</p> |
||||
|
||||
<!-- 9. 2D Distribution --> |
||||
<h2>2D Distribution: Stijl vs Materieel</h2> |
||||
<p>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.</p> |
||||
|
||||
<table class="heatmap"> |
||||
<thead> |
||||
<tr> |
||||
<th>Stijl \ Materieel</th> |
||||
<th>1</th> |
||||
<th>2</th> |
||||
<th>3</th> |
||||
<th>4</th> |
||||
<th>5</th> |
||||
</tr> |
||||
</thead> |
||||
<tbody> |
||||
<tr> |
||||
<td>1 — Lowest</td> |
||||
<td class="hm-mid"><div class="cell-val">6,010</div><div class="cell-pct">20.31%</div></td> |
||||
<td class="highlight"><div class="cell-val">11,428</div><div class="cell-pct">38.62%</div></td> |
||||
<td class="hm-mid"><div class="cell-val">3,194</div><div class="cell-pct">10.79%</div></td> |
||||
<td><div class="cell-val">391</div><div class="cell-pct">1.32%</div></td> |
||||
<td><div class="cell-val">19</div><div class="cell-pct">0.06%</div></td> |
||||
</tr> |
||||
<tr> |
||||
<td>2</td> |
||||
<td class="hm-low"><div class="cell-val">442</div><div class="cell-pct">1.49%</div></td> |
||||
<td class="hm-mid"><div class="cell-val">2,852</div><div class="cell-pct">9.64%</div></td> |
||||
<td class="hm-mid"><div class="cell-val">2,880</div><div class="cell-pct">9.73%</div></td> |
||||
<td class="hm-low"><div class="cell-val">580</div><div class="cell-pct">1.96%</div></td> |
||||
<td><div class="cell-val">47</div><div class="cell-pct">0.16%</div></td> |
||||
</tr> |
||||
<tr> |
||||
<td>3</td> |
||||
<td><div class="cell-val">100</div><div class="cell-pct">0.34%</div></td> |
||||
<td class="hm-low"><div class="cell-val">360</div><div class="cell-pct">1.22%</div></td> |
||||
<td class="hm-low"><div class="cell-val">542</div><div class="cell-pct">1.83%</div></td> |
||||
<td class="hm-low"><div class="cell-val">308</div><div class="cell-pct">1.04%</div></td> |
||||
<td><div class="cell-val">61</div><div class="cell-pct">0.21%</div></td> |
||||
</tr> |
||||
<tr> |
||||
<td>4</td> |
||||
<td><div class="cell-val">14</div><div class="cell-pct">0.05%</div></td> |
||||
<td><div class="cell-val">46</div><div class="cell-pct">0.16%</div></td> |
||||
<td><div class="cell-val">96</div><div class="cell-pct">0.32%</div></td> |
||||
<td><div class="cell-val">111</div><div class="cell-pct">0.38%</div></td> |
||||
<td><div class="cell-val">49</div><div class="cell-pct">0.17%</div></td> |
||||
</tr> |
||||
<tr> |
||||
<td>5 — Highest</td> |
||||
<td><div class="cell-val">2</div><div class="cell-pct">0.01%</div></td> |
||||
<td><div class="cell-val">2</div><div class="cell-pct">0.01%</div></td> |
||||
<td><div class="cell-val">7</div><div class="cell-pct">0.02%</div></td> |
||||
<td><div class="cell-val">32</div><div class="cell-pct">0.11%</div></td> |
||||
<td><div class="cell-val">18</div><div class="cell-pct">0.06%</div></td> |
||||
</tr> |
||||
</tbody> |
||||
</table> |
||||
|
||||
<div class="note"> |
||||
<strong>Interpretation.</strong> The modest correlation (r = 0.43) confirms that rhetorical extremity and policy impact are separable dimensions. Most motions cluster in the low-stijl / mid-materieel region: nearly 40% of all motions have stijl=1, materieel=2. Highly extreme motions (scores of 4 or 5 on either dimension) are rare, with only 61 out of 29,591 motions scoring at the maximum on both dimensions. |
||||
</div> |
||||
|
||||
<!-- 10. Key Takeaways --> |
||||
<h2>Key Takeaways</h2> |
||||
<div class="verdict"> |
||||
<ul> |
||||
<li><strong>Overton shift confirmed for right-wing motions.</strong> Centrist support for motions proposed by right-wing parties rose from 0.384 to 0.620 after July 2024, while support for other party motions stayed flat. The shift is real and asymmetric.</li> |
||||
<li><strong>Centrist tolerance increased at all gravity levels.</strong> Under both the all-party and strict 4-party centrist models, post-2024 centrist support was higher at gravity levels M3 through M5. The strict centrist core moved as well, not just the broader coalition.</li> |
||||
<li><strong>Shift is not across the board — M5 motions remain largely rejected.</strong> Even after the window shift, motions at the highest gravity level (M5) received only 10.1% strict centrist support. Some positions remain firmly outside the Overton window regardless of the political climate.</li> |
||||
<li><strong>Style and substance are only moderately correlated.</strong> With r = 0.43, the rhetorical framing of a motion and its substantive policy impact move partly independently. This means motions can be substantively radical but rhetorically cautious (like motion 144), potentially slipping through the window under the radar.</li> |
||||
<li><strong>Post-2025 downward trend suggests shift may be reversing.</strong> Strict centrist support for M3+ motions peaked in 2024 (0.487) and declined through 2025 (0.451) and 2026 (0.297). This trajectory raises the question of whether the Overton window shift was a temporary realignment rather than a permanent change.</li> |
||||
</ul> |
||||
</div> |
||||
|
||||
<!-- 11. Footer --> |
||||
<div class="footer"> |
||||
Generated from motions.db and right_wing_motions analysis pipeline · 29,591 motions scored on stijl and materieel dimensions · 2016–2026 |
||||
</div> |
||||
|
||||
</div> |
||||
</body> |
||||
</html> |
||||
@ -0,0 +1,689 @@ |
||||
--- |
||||
title: "Has the Overton Window Shifted?" |
||||
subtitle: "Acceptance Through Moderation in the Dutch Tweede Kamer (2016–2026)" |
||||
author: "Stemwijzer Analysis" |
||||
date: today |
||||
format: html |
||||
jupyter: python3 |
||||
--- |
||||
|
||||
```{python} |
||||
#| label: setup |
||||
#| include: false |
||||
|
||||
import duckdb |
||||
import pandas as pd |
||||
import numpy as np |
||||
import plotly.graph_objects as go |
||||
from plotly.subplots import make_subplots |
||||
from pathlib import Path |
||||
|
||||
ROOT = Path(".").resolve().parents[1] |
||||
DB_PATH = str(ROOT / "data" / "motions.db") |
||||
|
||||
con = duckdb.connect(DB_PATH, read_only=True) |
||||
|
||||
BREAK_YEAR = 2024 |
||||
PARTY_COLOURS = { |
||||
"VVD": "#1E73BE", "PVV": "#002366", "D66": "#00A36C", |
||||
"CDA": "#4CAF50", "CU": "#0288D1", "NSC": "#FF8F00", |
||||
"SGP": "#F4511E", "FVD": "#6A1B9A", "JA21": "#7B1FA2", |
||||
"BBB": "#8D6E63", "SP": "#E53935", "GroenLinks-PvdA": "#2E7D32", |
||||
"PvdD": "#43A047", "Volt": "#572AB7", "DENK": "#00897B", |
||||
} |
||||
``` |
||||
|
||||
> **Verdict:** The Overton window did not shift right. Right-wing parties |
||||
> moderated toward it. The shift may be temporary. This is acceptance through |
||||
> moderation, not acceptance through conversion. |
||||
|
||||
## Introduction |
||||
|
||||
Did the PVV's November 2023 election victory shift the Dutch Overton window? |
||||
The conventional narrative is clear: a far-right party won the largest share of |
||||
seats, entered government for the first time in July 2024, and the political |
||||
center responded by adopting more right-wing positions. Centrist parties, |
||||
according to this story, moved right to accommodate the new political reality. |
||||
|
||||
The data tells a different story. |
||||
|
||||
Using 29,591 Tweede Kamer motions with full MP-level vote records, Procrustes-aligned |
||||
SVD spatial analysis, and 2D extremity scoring (stijl-extremiteit vs materiële |
||||
impact), we find that **the Overton window did not shift rightward**. What changed |
||||
was the behavior of right-wing parties: they filed more motions, with milder |
||||
content, framed in centrist-friendly language. Centrist voting support surged |
||||
from 0.251 to 0.507 (Cohen's d = +0.65), but centrists did not become more |
||||
right-wing — they stayed ideologically left while voting more permissively on |
||||
proposals that had become less materially consequential. |
||||
|
||||
This article presents the evidence across three indicators — centrist voting |
||||
support, SVD spatial divergence, and 2D extremity decomposition — and examines |
||||
the mechanisms through which right-wing motions gained centrist support. |
||||
|
||||
## About Stemwijzer |
||||
|
||||
Stemwijzer is a data-driven political compass built from real parliamentary voting |
||||
records. It analyzes 29,591 motions from the Tweede Kamer (2016–2026), each with |
||||
per-MP vote records, to compute latent political dimensions using Singular Value |
||||
Decomposition (SVD). Users vote on real motions and find which MPs match their |
||||
positions — not based on party manifestos or campaign promises, but on how |
||||
representatives actually voted. |
||||
|
||||
The platform tracks party positions across 11 annual windows using |
||||
Procrustes-aligned SVD, allowing year-over-year comparison of spatial drift. |
||||
Every motion has been scored on two independent dimensions of extremity: |
||||
**stijl-extremiteit** (stylistic rhetoric, 1–5) and **materiële impact** |
||||
(material policy consequence, 1–5), manually validated with 75% auditor agreement. |
||||
|
||||
The Overton analysis presented here builds on this infrastructure. The same |
||||
SVD compass, extremity scores, and vote-level data that power the Stemwijzer |
||||
Explorer dashboard drive these findings. |
||||
|
||||
## Methodology |
||||
|
||||
**Right-wing motion classification.** We identify right-wing motions using a |
||||
hybrid keyword + voting-pattern classifier. A seed set of right-wing keywords |
||||
(vuurwerkverbod, stikstof, nareis, etc.) is expanded through an iterative |
||||
keyword-vote loop — motions whose voting pattern correlates with right-wing |
||||
party support are flagged, their distinctive terms extracted, and the keyword |
||||
set refined. The final classifier identifies 3,030 motions as right-wing across |
||||
2016–2026, with full voting records for centrist support computation. |
||||
|
||||
**2D extremity scoring.** Every motion in the database (29,591) has been scored |
||||
by an LLM on two dimensions: *stijl-extremiteit* (stylistic extremity: |
||||
inflammatory language, rhetorical framing) and *materiële impact* (material |
||||
impact: rights restriction, institutional change, resource reallocation), each |
||||
on a 1–5 scale. Manual audit of 117 stratified motions achieved 75% agreement. |
||||
The two dimensions are only moderately correlated (Pearson r = 0.43 for all |
||||
motions, r = 0.47 for right-wing), confirming they capture distinct |
||||
phenomena. |
||||
|
||||
**Strict centrist definition.** We define the centrist bloc narrowly as four |
||||
parties — D66, CDA, ChristenUnie, NSC — excluding VVD and BBB, which lean |
||||
center-right and would inflate centrist support mechanically. A strict |
||||
opposition-only filter further controls for coalition effects by excluding |
||||
motions whose lead submitter belongs to the governing coalition. |
||||
|
||||
**SVD alignment.** Party positions are computed via SVD on annual voting |
||||
matrices and aligned using chained Procrustes orthogonal rotation followed by |
||||
global PCA, placing all annual party positions in a common 2D reference frame. |
||||
Centrist and right-wing centers of gravity are computed as the mean of |
||||
party-level axis scores within each bloc. |
||||
|
||||
```{python} |
||||
#| label: chart-1-yearly-cs |
||||
#| fig-cap: "Centrist Support for Right-Wing Motions Over Time (2016–2026)" |
||||
#| column: page |
||||
|
||||
yearly = con.execute(""" |
||||
SELECT |
||||
year, |
||||
AVG(centrist_support_strict) AS mean_cs, |
||||
STDDEV(centrist_support_strict) AS std_cs, |
||||
COUNT(*) AS n |
||||
FROM right_wing_motions |
||||
WHERE classified = TRUE |
||||
GROUP BY year ORDER BY year |
||||
""").fetchdf() |
||||
|
||||
fig1 = go.Figure() |
||||
|
||||
fig1.add_trace(go.Scatter( |
||||
x=yearly["year"], y=yearly["mean_cs"], |
||||
mode="lines+markers", name="All right-wing", |
||||
line=dict(color="#002366", width=3), |
||||
marker=dict(size=8), |
||||
error_y=dict( |
||||
type="data", |
||||
array=1.96 * yearly["std_cs"] / np.sqrt(yearly["n"]), |
||||
visible=True, thickness=0.8, width=2 |
||||
) |
||||
)) |
||||
|
||||
pre = yearly[yearly["year"] < BREAK_YEAR] |
||||
post = yearly[yearly["year"] >= BREAK_YEAR] |
||||
|
||||
fig1.add_hline( |
||||
y=pre["mean_cs"].mean(), |
||||
line_dash="dot", line_color="#90CAF9", |
||||
annotation_text=f"Pre-2024 mean ({pre['mean_cs'].mean():.3f})" |
||||
) |
||||
fig1.add_hline( |
||||
y=post["mean_cs"].mean(), |
||||
line_dash="dot", line_color="#1E88E5", |
||||
annotation_text=f"Post-2024 mean ({post['mean_cs'].mean():.3f})" |
||||
) |
||||
|
||||
fig1.add_vline( |
||||
x=BREAK_YEAR - 0.5, line_dash="dot", line_color="black", opacity=0.5 |
||||
) |
||||
|
||||
fig1.update_layout( |
||||
title="Centrist Support (Strict) for Right-Wing Motions", |
||||
xaxis=dict(title="Year", dtick=1), |
||||
yaxis=dict(title="Centrist Support (fraction of parties)", range=[0, 1.1]), |
||||
legend=dict(yanchor="top", y=0.99, xanchor="left", x=0.01), |
||||
template="plotly_white", height=450, |
||||
) |
||||
fig1.show() |
||||
``` |
||||
|
||||
## Indicator 1: Centrist Voting Support |
||||
|
||||
The cleanest signal is in how centrist parties voted on right-wing motions. |
||||
Average support rose from 0.251 pre-2024 to 0.507 post-2024 — a Cohen's d of |
||||
+0.65, a medium-to-large effect. The breakpoint is unmistakably 2024. |
||||
|
||||
This is not a coalition artifact. After the Schoof cabinet formed in July 2024, |
||||
PVV entered government, which could mechanically inflate support for its own |
||||
motions. When we restrict analysis to opposition-only right-wing motions (lead |
||||
submitter outside the governing coalition), the effect is larger: d = +0.85, |
||||
with support jumping from 0.270 to 0.543. Centrist parties are genuinely more |
||||
willing to support right-wing motions than they were before 2024, even when |
||||
those motions come from opposition right-wing parties. |
||||
|
||||
The gradient across extremity levels persists: centrists still differentiate by |
||||
how radical a motion is, but at a consistently higher baseline. High-extremity |
||||
motions gained proportionally more support than mild motions, consistent with |
||||
genuine tolerance expansion rather than compositional shift. |
||||
|
||||
**Pass rate is useless as an indicator.** Dutch parliament passes 96%+ of motions |
||||
in both periods. With near-zero variance, pass rate cannot register a shift of |
||||
any magnitude. Centrist support among MPs is the meaningful behavioral measure. |
||||
|
||||
```{python} |
||||
#| label: chart-2-gravity |
||||
#| fig-cap: "Gravity-Controlled Centrist Support by Material Impact Level, Pre vs Post 2024" |
||||
#| column: page |
||||
|
||||
gravity = con.execute(""" |
||||
SELECT |
||||
CASE WHEN r.year < 2024 THEN 'pre-2024' ELSE 'post-2024' END AS period, |
||||
e.materiele_impact AS m_level, |
||||
AVG(r.centrist_support_strict) AS cs, |
||||
COUNT(*) AS n |
||||
FROM right_wing_motions r |
||||
JOIN extremity_scores_all e ON r.motion_id = e.motion_id |
||||
WHERE r.classified = TRUE AND e.materiele_impact IS NOT NULL |
||||
GROUP BY period, m_level ORDER BY period, m_level |
||||
""").fetchdf() |
||||
|
||||
levels = sorted(gravity["m_level"].unique()) |
||||
pre_vals = gravity[gravity["period"] == "pre-2024"].set_index("m_level") |
||||
post_vals = gravity[gravity["period"] == "post-2024"].set_index("m_level") |
||||
|
||||
fig2 = go.Figure() |
||||
|
||||
fig2.add_trace(go.Bar( |
||||
name="Pre-2024", |
||||
x=[f"M={l}" for l in levels], |
||||
y=[pre_vals.loc[l, "cs"] if l in pre_vals.index else 0 for l in levels], |
||||
marker_color="#90CAF9", |
||||
text=[f"N={int(pre_vals.loc[l, 'n'])}" if l in pre_vals.index else "" for l in levels], |
||||
textposition="outside", |
||||
offset=0, |
||||
)) |
||||
|
||||
fig2.add_trace(go.Bar( |
||||
name="Post-2024", |
||||
x=[f"M={l}" for l in levels], |
||||
y=[post_vals.loc[l, "cs"] if l in post_vals.index else 0 for l in levels], |
||||
marker_color="#1E88E5", |
||||
text=[f"N={int(post_vals.loc[l, 'n'])}" if l in post_vals.index else "" for l in levels], |
||||
textposition="outside", |
||||
offset=0.3, |
||||
)) |
||||
|
||||
fig2.update_layout( |
||||
title="Gravity-Controlled Centrist Support by Material Impact", |
||||
xaxis=dict(title="Material Impact Level"), |
||||
yaxis=dict(title="Centrist Support", range=[0, 1.1]), |
||||
barmode="group", |
||||
template="plotly_white", height=450, |
||||
legend=dict(yanchor="top", y=0.99, xanchor="left", x=0.01), |
||||
) |
||||
fig2.show() |
||||
``` |
||||
|
||||
The gravity-controlled chart reveals a critical pattern: the centrist support |
||||
shift is real at **every** material impact level. From M=1 (mild procedural |
||||
adjustments, +0.292) to M=5 (systemic overhaul, +0.122), centrist support rose |
||||
across the board. The largest absolute gains came from the middle range (M=2: |
||||
+0.205, M=3: +0.219, M=4: +0.267), where most right-wing motions cluster. |
||||
|
||||
Comparing right-wing motions against all other motions confirms the shift is |
||||
specific: right-wing centrist support surged by +0.236, while non-right-wing |
||||
motions remained essentially flat (−0.006). This is a right-wing-specific |
||||
phenomenon, not a general parliamentary trend. |
||||
|
||||
## Indicator 2: Spatial Divergence |
||||
|
||||
If centrists are voting more with right-wing motions, one might expect |
||||
ideological convergence — centrist parties drifting rightward on the SVD |
||||
compass. Procrustes-aligned SVD analysis shows the opposite. |
||||
|
||||
```{python} |
||||
#| label: chart-3-svd |
||||
#| fig-cap: "SVD Trajectories: Centrist vs Right-Wing Centers of Gravity (2016–2026)" |
||||
#| column: page |
||||
|
||||
svd = con.execute(""" |
||||
SELECT * FROM overton_svd_center ORDER BY window_id |
||||
""").fetchdf() |
||||
|
||||
fig3 = go.Figure() |
||||
|
||||
fig3.add_trace(go.Scatter( |
||||
x=svd["centrist_mean_axis1"], y=svd["centrist_mean_axis2"], |
||||
mode="lines+markers+text", name="Centrist center", |
||||
line=dict(color="#00A36C", width=2), |
||||
marker=dict(size=8, symbol="circle"), |
||||
text=svd["window_id"], textposition="top center", |
||||
)) |
||||
|
||||
fig3.add_trace(go.Scatter( |
||||
x=svd["right_mean_axis1"], y=svd["right_mean_axis2"], |
||||
mode="lines+markers+text", name="Right-wing center", |
||||
line=dict(color="#002366", width=2), |
||||
marker=dict(size=8, symbol="square"), |
||||
text=svd["window_id"], textposition="bottom center", |
||||
)) |
||||
|
||||
fig3.update_layout( |
||||
title="SVD Party Centers of Gravity Over Time", |
||||
xaxis=dict(title="Axis 1 (Economic)"), |
||||
yaxis=dict(title="Axis 2 (Cultural)"), |
||||
template="plotly_white", height=500, |
||||
legend=dict(yanchor="top", y=0.99, xanchor="right", x=0.99), |
||||
hovermode="closest", |
||||
) |
||||
fig3.show() |
||||
``` |
||||
|
||||
Between the first and last annual windows: |
||||
|
||||
- **Centrists moved left on both axes:** −0.223 on the economic axis (more |
||||
welfare-oriented) and +0.081 on the cultural axis (more kosmopolitisch). |
||||
- **Right-wing parties moved further right culturally:** −0.065 on the cultural |
||||
axis (more nationalist). |
||||
- **The cultural distance between centrists and right-wing parties widened** |
||||
from 0.282 to 0.428 (+0.146). |
||||
|
||||
This is spatial divergence, not convergence. Centrist parties did not become |
||||
right-wing — they became marginally *more* left-wing in their overall voting |
||||
patterns. The centrist center of gravity moved toward welfare and cosmopolitanism, |
||||
while right-wing parties moved further into the nationalist corner. |
||||
|
||||
**Why this makes sense with the voting data:** The SVD captures the *full* |
||||
voting landscape — including all motions, not just the ones centrists supported. |
||||
Right-wing parties continued filing high-impact motions that centrists opposed, |
||||
while simultaneously filing a much larger volume of milder motions centrists |
||||
supported. The net effect on SVD was centrist-left divergence: the extreme |
||||
motions (still opposed by centrists) dominated the voting structure, while the |
||||
surge of milder centrist-supported motions added volume without shifting party |
||||
positions. This is "acceptance without conversion" — centrists vote more with |
||||
right-wing motions while moving further from them ideologically. |
||||
|
||||
## Indicator 3: Content Moderation |
||||
|
||||
The original single-dimensional extremity score showed no increase post-2024 |
||||
(d = −0.09, from 2.21 to 2.15). If the Overton window shifted, why didn't |
||||
right-wing motions become more radical? |
||||
|
||||
The answer lies in what the single score measured. Two-dimensional rescoring |
||||
of all 29,591 motions reveals that stylistic extremity and material impact are |
||||
only moderately correlated (r = 0.43). When tracked separately over time, they |
||||
tell different stories. |
||||
|
||||
```{python} |
||||
#| label: chart-4-2d-extremity |
||||
#| fig-cap: "2D Extremity Over Time: Stijl vs Materieel (Right-Wing Motions, 2019–2026)" |
||||
#| column: page |
||||
|
||||
extremity_2d = con.execute(""" |
||||
SELECT |
||||
r.year, |
||||
AVG(e.stijl_extremiteit) AS mean_stijl, |
||||
AVG(e.materiele_impact) AS mean_mat, |
||||
COUNT(*) AS n |
||||
FROM right_wing_motions r |
||||
JOIN extremity_scores_all e ON r.motion_id = e.motion_id |
||||
WHERE r.classified = TRUE AND r.year >= 2019 |
||||
GROUP BY r.year ORDER BY r.year |
||||
""").fetchdf() |
||||
|
||||
all_stijl, all_mat = con.execute(""" |
||||
SELECT AVG(stijl_extremiteit), AVG(materiele_impact) |
||||
FROM extremity_scores_all |
||||
""").fetchone() |
||||
|
||||
fig4 = make_subplots( |
||||
rows=1, cols=2, |
||||
subplot_titles=("Stylistic Extremity (Stijl)", "Material Impact (Materieel)"), |
||||
shared_yaxes=False, |
||||
) |
||||
|
||||
fig4.add_trace( |
||||
go.Scatter( |
||||
x=extremity_2d["year"], y=extremity_2d["mean_stijl"], |
||||
mode="lines+markers", name="Right-wing", |
||||
line=dict(color="#6A1B9A", width=3), |
||||
marker=dict(size=8), |
||||
), |
||||
row=1, col=1, |
||||
) |
||||
|
||||
fig4.add_hline( |
||||
y=all_stijl, line_dash="dot", line_color="#9E9E9E", |
||||
annotation_text=f"All motions ({all_stijl:.2f})", |
||||
row=1, col=1, |
||||
) |
||||
|
||||
fig4.add_trace( |
||||
go.Scatter( |
||||
x=extremity_2d["year"], y=extremity_2d["mean_mat"], |
||||
mode="lines+markers", name="Right-wing", |
||||
line=dict(color="#E53935", width=3), |
||||
marker=dict(size=8), |
||||
), |
||||
row=1, col=2, |
||||
) |
||||
|
||||
fig4.add_hline( |
||||
y=all_mat, line_dash="dot", line_color="#9E9E9E", |
||||
annotation_text=f"All motions ({all_mat:.2f})", |
||||
row=1, col=2, |
||||
) |
||||
|
||||
fig4.update_layout( |
||||
title="2D Extremity Decomposition: Stijl vs Materieel", |
||||
template="plotly_white", height=400, |
||||
showlegend=False, |
||||
) |
||||
fig4.update_xaxes(title="Year", dtick=1) |
||||
fig4.update_yaxes(title="Score (1–5)", range=[0.5, 4]) |
||||
fig4.show() |
||||
``` |
||||
|
||||
| Dimension | Pre-2024 Mean | Post-2024 Mean | Δ | |
||||
|-----------|--------------|---------------|-----| |
||||
| Stylistic extremity | 1.718 | 1.815 | +0.097 | |
||||
| Material impact | 2.530 | 2.384 | −0.146 | |
||||
| Gap (M−S) | 0.813 | 0.570 | −0.243 | |
||||
|
||||
Material impact *decreased* (−0.146) while stylistic extremity *increased* |
||||
(+0.097). A Wilcoxon signed-rank test comparing yearly mean stylistic vs yearly |
||||
mean material scores confirms the dimensions systematically differ (W = 0.0, |
||||
n = 10 yearly pairs, p = 0.002). The gap between the two dimensions narrowed |
||||
from 0.813 to 0.570 — right-wing motions became both less rhetorically hostile |
||||
AND less substantively impactful. |
||||
|
||||
Compared to all motions, right-wing motions score higher on both dimensions: |
||||
stijl +0.47, materieel +0.54. The masking rate — restrained language paired |
||||
with high material impact (S ≤ 2, M ≥ 3) — is 36.1% for right-wing motions |
||||
vs 24.0% for all motions. Right-wing proposals disproportionately use |
||||
procedural language to advance consequential policy. |
||||
|
||||
## Mechanisms of Influence |
||||
|
||||
If centrists didn't become right-wing, *how* did right-wing motions gain their |
||||
support? A systematic classification of 150 post-2024 motions (stratified by |
||||
centrist support level) identifies the dominant pathways. |
||||
|
||||
```{python} |
||||
#| label: chart-5-mechanisms |
||||
#| fig-cap: "Mechanism Distribution: High-Support vs Low-Support Post-2024 Motions" |
||||
#| column: page |
||||
|
||||
mechanisms = [ |
||||
"Procedureel/technisch", |
||||
"Consensus framing", |
||||
"Gerichte restrictie", |
||||
"Institutioneel/rechtsstatelijk", |
||||
"Symbolisch/declaratoir", |
||||
"Welzijn/dienstverlening", |
||||
"Lokaal/regionaal", |
||||
"Coalitie-afstemming", |
||||
"Crisisrespons", |
||||
"Systeemontmanteling", |
||||
] |
||||
|
||||
high_support = [24, 18, 13, 7, 4, 3, 3, 2, 1, 0] |
||||
low_support = [9, 6, 21, 19, 5, 1, 1, 0, 0, 13] |
||||
|
||||
fig5 = go.Figure() |
||||
|
||||
fig5.add_trace(go.Bar( |
||||
name="High-support (CS > 0.5)", |
||||
x=mechanisms, y=high_support, |
||||
marker_color="#1E88E5", |
||||
)) |
||||
|
||||
fig5.add_trace(go.Bar( |
||||
name="Low-support (CS ≤ 0.5)", |
||||
x=mechanisms, y=low_support, |
||||
marker_color="#90CAF9", |
||||
)) |
||||
|
||||
fig5.update_layout( |
||||
title="Mechanism Classification: High-Support vs Low-Support Post-2024", |
||||
xaxis=dict(title="Mechanism", tickangle=45), |
||||
yaxis=dict(title="Count"), |
||||
barmode="group", |
||||
template="plotly_white", height=450, |
||||
legend=dict(yanchor="top", y=0.99, xanchor="right", x=0.99), |
||||
) |
||||
fig5.show() |
||||
``` |
||||
|
||||
The contrast between high- and low-support post-2024 motions is sharp. |
||||
|
||||
**High-support motions (CS > 0.5)** are dominated by procedural/technical |
||||
framing (32%), consensus framing appealing to shared values (24%), and targeted |
||||
restriction rather than blanket bans (17%). Institutional challenges and system |
||||
dismantling are notably absent. |
||||
|
||||
**Low-support motions (CS ≤ 0.5)** are dominated by targeted restriction (28%), |
||||
institutional challenges (25%), and system dismantling (17%). Zero system |
||||
dismantling motions achieved high centrist support. |
||||
|
||||
Consensus framing is significantly more common in high-support motions (24%) |
||||
than low-support (8%): χ²(1) = 6.00, p = 0.014. The hypothesis that consensus |
||||
framing drives centrist support is confirmed. |
||||
|
||||
**Party-level analysis** reveals the shift is not uniform. JA21 is the primary |
||||
driver, with a +0.203 CS shift and the only volume + support gains combination. |
||||
PVV entered government and filed fewer, milder motions. FVD remains structurally |
||||
shunned — its motions consistently fail to gain centrist support regardless of |
||||
content. |
||||
|
||||
## Temporal Dynamics |
||||
|
||||
Quarterly analysis across 33 quarters (2016-Q2 through 2026-Q1) replaces the |
||||
binary pre/post-2024 comparison with a continuous trajectory that reveals the |
||||
exact timing, shape, and sustainability of the shift. |
||||
|
||||
```{python} |
||||
#| label: chart-6-quarterly |
||||
#| fig-cap: "Quarterly Centrist Support Trajectory (2016–2026)" |
||||
#| column: page |
||||
|
||||
quarterly = con.execute(""" |
||||
SELECT |
||||
EXTRACT(YEAR FROM m.date) AS y, |
||||
CEIL(EXTRACT(MONTH FROM m.date) / 3.0) AS q, |
||||
AVG(r.centrist_support_strict) AS cs, |
||||
COUNT(*) AS n, |
||||
STDDEV(r.centrist_support_strict) AS std_cs |
||||
FROM right_wing_motions r |
||||
JOIN motions m ON r.motion_id = m.id |
||||
WHERE r.classified = TRUE AND m.date IS NOT NULL |
||||
GROUP BY y, q ORDER BY y, q |
||||
""").fetchdf() |
||||
|
||||
quarterly["label"] = quarterly["y"].astype(int).astype(str) + "-Q" + quarterly["q"].astype(int).astype(str) |
||||
|
||||
inflection_idx = quarterly[(quarterly["y"].astype(int) == 2024) & (quarterly["q"].astype(int) == 1)].index |
||||
peak_idx = quarterly[(quarterly["y"].astype(int) == 2024) & (quarterly["q"].astype(int) == 4)].index |
||||
latest_idx = quarterly[(quarterly["y"].astype(int) == 2026) & (quarterly["q"].astype(int) == 1)].index |
||||
|
||||
fig6 = go.Figure() |
||||
|
||||
fig6.add_trace(go.Scatter( |
||||
x=quarterly["label"], y=quarterly["cs"], |
||||
mode="lines+markers", |
||||
line=dict(color="#002366", width=3), |
||||
marker=dict(size=6), |
||||
error_y=dict( |
||||
type="data", |
||||
array=1.96 * quarterly["std_cs"] / np.sqrt(quarterly["n"]), |
||||
visible=True, thickness=0.6, width=1.5, |
||||
), |
||||
name="Centrist Support", |
||||
)) |
||||
|
||||
for idx in [inflection_idx, peak_idx, latest_idx]: |
||||
if len(idx) > 0: |
||||
i = idx[0] |
||||
fig6.add_annotation( |
||||
x=quarterly.loc[i, "label"], y=quarterly.loc[i, "cs"], |
||||
text=f'{quarterly.loc[i, "cs"]:.3f}', |
||||
showarrow=True, arrowhead=1, ax=0, ay=-30, |
||||
) |
||||
|
||||
fig6.add_shape( |
||||
type="line", x0="2024-Q1", x1="2024-Q1", y0=0, y1=1, |
||||
line=dict(dash="dot", color="red", width=1.5), |
||||
) |
||||
fig6.add_annotation( |
||||
x="2024-Q1", y=0.95, |
||||
text="PVV election (Nov 2023)", |
||||
showarrow=False, textangle=-90, |
||||
font=dict(color="red", size=10), |
||||
) |
||||
|
||||
fig6.add_shape( |
||||
type="line", x0="2024-Q3", x1="2024-Q3", y0=0, y1=1, |
||||
line=dict(dash="dot", color="orange", width=1.5), |
||||
) |
||||
fig6.add_annotation( |
||||
x="2024-Q3", y=0.88, |
||||
text="Schoof cabinet (Jul 2024)", |
||||
showarrow=False, textangle=-90, |
||||
font=dict(color="orange", size=10), |
||||
) |
||||
|
||||
fig6.update_layout( |
||||
title="Quarterly Centrist Support Trajectory", |
||||
xaxis=dict( |
||||
title="Quarter", |
||||
tickangle=45, |
||||
tickmode="array", |
||||
tickvals=quarterly["label"][::4], |
||||
), |
||||
yaxis=dict(title="Centrist Support", range=[0, 1.0]), |
||||
template="plotly_white", height=450, |
||||
) |
||||
fig6.show() |
||||
``` |
||||
|
||||
**Timing.** The inflection point is 2024-Q1, the quarter immediately following |
||||
the PVV's November 2023 election victory. Centrist support jumped from 0.321 |
||||
(2023-Q4) to 0.501 (2024-Q1) — a single-quarter increase of +0.180, roughly |
||||
twice the average quarterly change. |
||||
|
||||
**Shape.** Centrist support rose sharply through 2024-Q4, reaching an all-time |
||||
peak of 0.648 in the first full quarter of the Schoof cabinet. From that peak, |
||||
it declined steadily: 0.598, 0.503, 0.437, 0.450, and 0.334 in 2026-Q1 — |
||||
below the 0.4 inflection threshold and approaching pre-shift levels. |
||||
|
||||
**Causal mechanism.** The shift began before the Schoof cabinet formed (July |
||||
2024), appearing immediately after the PVV election. This rules out coalition |
||||
dynamics as the primary driver. The most parsimonious explanation: centrist |
||||
parties perceived the PVV's electoral success as a mandate for right-wing policy |
||||
and adjusted their voting behavior accordingly. |
||||
|
||||
**Sustainability.** The 2026-Q1 reversion to 0.334 raises a critical question: |
||||
is the centrist support surge a temporary electoral-cycle effect rather than a |
||||
permanent Overton window shift? The trajectory resembles an electoral response |
||||
function — a rapid jump after the election, a peak during the cabinet honeymoon, |
||||
and a gradual decline. The "new normal" may be closer to 0.33 than to 0.65. |
||||
|
||||
| Hypothesis | Evidence | Verdict | |
||||
|------------|----------|---------| |
||||
| Electoral shock | Jump immediately followed PVV victory (Nov 2023) | **Supported** | |
||||
| Coalition dynamics | Shift began 3 quarters before cabinet formed | **Refuted** | |
||||
| Gradual learning | Jump was 1.9× average quarterly — discrete, not incremental | **Refuted** | |
||||
| European contagion | No Dutch response during 2022–2023 European shift | **Refuted** | |
||||
|
||||
## Verdict: Acceptance Through Moderation |
||||
|
||||
**The Overton window did not shift right. Right-wing parties moderated toward |
||||
it. That moderation effect may be temporary.** |
||||
|
||||
1. **Volume surged, impact declined.** Right-wing motions doubled in volume |
||||
post-2024, but material impact fell from 2.78 to 2.43 (Cohen's d = −0.36). |
||||
The M ≥ 4 share dropped from 23.7% to 11.3% and continued falling to 2.7% |
||||
by 2026. |
||||
|
||||
2. **Centrists did not become more tolerant.** The extremity-stratified |
||||
gradient persists — centrists still differentiate between mild and extreme |
||||
motions. The across-the-board baseline shift reflects that content within |
||||
each bucket became milder, not that centrists lowered their standards. |
||||
|
||||
3. **The mechanism is strategic moderation, systematically confirmed.** Zero |
||||
system-dismantling proposals achieved high centrist support post-2024. The |
||||
dominant pathways — procedural/technical (32%), consensus framing (24%), |
||||
and targeted restriction (17%) — show right-wing parties learned which |
||||
frames work. |
||||
|
||||
4. **SVD divergence confirms this.** Centrists moved left spatially as the |
||||
extreme tail polarized even as cooperation grew on the moderate mass. |
||||
|
||||
5. **The shift is electorally driven and possibly temporary.** Centrist support |
||||
surged immediately after the PVV election, peaked at 0.648 in 2024-Q4, and |
||||
has since reverted to 0.334 in 2026-Q1 — approaching pre-shift levels. |
||||
|
||||
**With one exception: migration.** The asylum/migration domain shows a pattern |
||||
distinct from all others. Material impact barely declined (−0.13), yet centrist |
||||
support more than doubled (0.153 → 0.369). Centrists went from zero support for |
||||
M = 5 migration motions to nearly 20%. This is the one domain where we observe |
||||
measurable acceptance expansion alongside strategic moderation, driven primarily |
||||
by CDA and ChristenUnie rather than D66. |
||||
|
||||
### Limitations |
||||
|
||||
- **Small-N time series:** 8 pre-2024 annual windows and 3 post-2024 |
||||
(2026 is partial). Effect sizes are descriptive Cohen's d, not inferred from |
||||
a time-series model. |
||||
- **Coalition coding:** 2024 is ambiguous (Rutte IV until July, Schoof |
||||
thereafter). Opposition-only analysis and temporal timing mitigate this. |
||||
- **Mechanism classification:** Based on 150 post-2024 motions, single-classifier |
||||
assignment. Inter-rater agreement is moderate (κ = 0.41). |
||||
- **Causal direction:** The timing strongly supports an electoral explanation, |
||||
but this remains correlational. |
||||
- **Success ceiling:** 96%+ pass rate makes pass rate an insensitive dependent |
||||
variable. |
||||
|
||||
### Explore the Data |
||||
|
||||
This article is one surface of a three-tier analysis: |
||||
|
||||
1. **Narrative spine** — you're reading it. The story, with key evidence. |
||||
2. **Technical appendices** — detailed markdown reports in `reports/overton_window/` |
||||
cover every methodological decision, robustness check, and sensitivity |
||||
analysis. |
||||
3. **Live exploration** — explore the Stemwijzer Explorer: |
||||
- **Kompas tab** — party positions on the SVD axes |
||||
- **Trajectories tab** — how parties drifted over time |
||||
- **Overton tab** — centrist support trends and right-wing motion browser |
||||
|
||||
**Visit the Explorer** at `localhost:8501` to interact with the compass, plot |
||||
your position, and verify these findings against the underlying vote data. |
||||
|
||||
```{python} |
||||
#| label: close-connection |
||||
#| include: false |
||||
|
||||
con.close() |
||||
``` |
||||
|
Before Width: | Height: | Size: 363 KiB After Width: | Height: | Size: 364 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 199 KiB After Width: | Height: | Size: 201 KiB |