fix(overton): strict centrist definition + left support analysis

- Reclassified centrist to {D66, CDA, CU, NSC} — removing VVD/BBB
  which are center-right coalition partners
- Added centrist_support_strict (0.251→0.507, d=+0.65), center_right_support,
  and left_support_mp columns via migration script
- Figure 1 now shows center-right (VVD/BBB) support as orange dashed line
- New Figure 3: bar chart of left-party support for right-wing motions
  (0.268→0.202, left opposition hardened)
- New report Section 6 covering left-wing support trends
- All analysis now uses strict centrist definition throughout
main
Sven Geboers 1 month ago
parent e478235c84
commit 2a081ade25
  1. 62
      analysis/right_wing/migrate_mp_level_metrics.py
  2. 184
      analysis/right_wing/overton_breakpoint_analysis.py
  3. 81
      reports/overton_window/breakpoint_analysis.md
  4. BIN
      reports/overton_window/breakpoint_figure_1.png
  5. BIN
      reports/overton_window/breakpoint_figure_3.png

@ -1,15 +1,32 @@
"""Add MP-weighted centrist_support column to right_wing_motions.
"""Add MP-weighted support columns to right_wing_motions.
The existing centrist_support is party-bloc-level (fraction of centrist
parties where >=50% of MPs voted voor). This adds centrist_support_mp which
is the fraction of individual centrist MPs who voted voor, weighted by party
size.
Adds centrist_support_mp, centrist_support_strict, center_right_support,
and left_support_mp all computed as the fraction of individual MPs
within each party set who voted 'voor'.
"""
from __future__ import annotations
import duckdb
import sys
from pathlib import Path
ROOT = Path(__file__).parent.parent.parent.resolve()
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
import duckdb
from analysis.config import CANONICAL_LEFT
CANONICAL_CENTRIST = frozenset({"VVD", "D66", "CDA", "NSC", "BBB", "CU"})
CANONICAL_CENTRIST_STRICT = frozenset({"D66", "CDA", "CU", "NSC"})
CANONICAL_CENTER_RIGHT = frozenset({"VVD", "BBB"})
COLUMNS = [
("centrist_support_mp", CANONICAL_CENTRIST),
("centrist_support_strict", CANONICAL_CENTRIST_STRICT),
("center_right_support", CANONICAL_CENTER_RIGHT),
("left_support_mp", CANONICAL_LEFT),
]
def compute_mp_support(
@ -51,16 +68,18 @@ def main(db_path: str = "data/motions.db"):
pv = mv.setdefault(party, {"voor": 0, "tegen": 0, "afwezig": 0})
pv[vote] = pv.get(vote, 0) + n
# Add column
col_check = con.execute(
"SELECT column_name FROM information_schema.columns "
"WHERE table_name = 'right_wing_motions' AND column_name = 'centrist_support_mp'"
).fetchone()
if col_check is None:
con.execute(
"ALTER TABLE right_wing_motions ADD COLUMN centrist_support_mp DOUBLE"
)
print("Added centrist_support_mp column")
# Add columns if missing
for col_name, _party_set in COLUMNS:
col_check = con.execute(
"SELECT column_name FROM information_schema.columns "
"WHERE table_name = 'right_wing_motions' AND column_name = ?",
[col_name],
).fetchone()
if col_check is None:
con.execute(
f"ALTER TABLE right_wing_motions ADD COLUMN {col_name} DOUBLE"
)
print(f"Added {col_name} column")
# Update rows
rows = con.execute(
@ -74,11 +93,12 @@ def main(db_path: str = "data/motions.db"):
if votes is None:
skipped += 1
continue
cs_mp = compute_mp_support(votes, CANONICAL_CENTRIST)
con.execute(
"UPDATE right_wing_motions SET centrist_support_mp = ? WHERE motion_id = ?",
[cs_mp, motion_id],
)
for col_name, party_set in COLUMNS:
val = compute_mp_support(votes, party_set)
con.execute(
f"UPDATE right_wing_motions SET {col_name} = ? WHERE motion_id = ?",
[val, motion_id],
)
updated += 1
con.close()

@ -100,7 +100,8 @@ def compute_yearly_rw_metrics(con: duckdb.DuckDBPyConnection) -> dict[int, dict]
r.motion_id,
r.year,
r.title,
r.centrist_support,
r.centrist_support_strict,
r.center_right_support,
r.right_support,
r.left_opposition,
r.category,
@ -118,7 +119,8 @@ def compute_yearly_rw_metrics(con: duckdb.DuckDBPyConnection) -> dict[int, dict]
yearly: dict[int, dict[str, Any]] = {}
for year in range(YEAR_MIN, YEAR_MAX + 1):
yearly[year] = {
"centrist_support": [],
"centrist_support_strict": [],
"center_right_support": [],
"right_support": [],
"left_opposition": [],
"extremity": [],
@ -128,10 +130,11 @@ def compute_yearly_rw_metrics(con: duckdb.DuckDBPyConnection) -> dict[int, dict]
"motion_ids": [],
}
for mid, year, title, cs, rs, lo, cat, ext, vr_json, wm in rows:
for mid, year, title, cst, crs, rs, lo, cat, ext, vr_json, wm in rows:
if year is None or year < YEAR_MIN or year > YEAR_MAX:
continue
yearly[year]["centrist_support"].append(cs if cs is not None else np.nan)
yearly[year]["centrist_support_strict"].append(cst if cst is not None else np.nan)
yearly[year]["center_right_support"].append(crs if crs is not None else np.nan)
yearly[year]["right_support"].append(rs if rs is not None else np.nan)
yearly[year]["left_opposition"].append(lo if lo is not None else np.nan)
yearly[year]["extremity"].append(ext if ext is not None else np.nan)
@ -286,7 +289,7 @@ def compute_opposition_metrics(
opp: dict[int, dict[str, list]] = {}
for year in range(YEAR_MIN, YEAR_MAX + 1):
opp[year] = {
"centrist_support": [],
"centrist_support_strict": [],
"extremity": [],
"passed": [],
"n": 0,
@ -310,7 +313,7 @@ def compute_opposition_metrics(
if submitter_party in coal:
continue
opp[year]["centrist_support"].append(d["centrist_support"][idx])
opp[year]["centrist_support_strict"].append(d["centrist_support_strict"][idx])
opp[year]["extremity"].append(d["extremity"][idx])
opp[year]["passed"].append(d["passed"][idx])
opp[year]["n"] += 1
@ -326,14 +329,14 @@ def compute_domain_metrics(
non_mig: dict[int, dict[str, list]] = {}
for year in range(YEAR_MIN, YEAR_MAX + 1):
mig[year] = {"centrist_support": [], "extremity": [], "passed": [], "n": 0}
non_mig[year] = {"centrist_support": [], "extremity": [], "passed": [], "n": 0}
mig[year] = {"centrist_support_strict": [], "extremity": [], "passed": [], "n": 0}
non_mig[year] = {"centrist_support_strict": [], "extremity": [], "passed": [], "n": 0}
for year, d in yearly_raw.items():
for idx in range(len(d["titles"])):
cat = d["categories"][idx]
target = mig if cat == "asiel/vreemdelingen" else non_mig
target[year]["centrist_support"].append(d["centrist_support"][idx])
target[year]["centrist_support_strict"].append(d["centrist_support_strict"][idx])
target[year]["extremity"].append(d["extremity"][idx])
target[year]["passed"].append(d["passed"][idx])
target[year]["n"] += 1
@ -361,7 +364,7 @@ def compute_extremity_stratified(
period = "pre-2024" if year < BREAK_YEAR else "post-2024"
for idx in range(len(d["titles"])):
ext = d["extremity"][idx]
cs = d["centrist_support"][idx]
cs = d["centrist_support_strict"][idx]
if np.isnan(ext) or cs is None or (isinstance(cs, float) and np.isnan(cs)):
continue
if ext < 2:
@ -377,17 +380,33 @@ def compute_extremity_stratified(
return pre_post
def compute_left_support_yearly(con: duckdb.DuckDBPyConnection) -> dict[int, dict]:
"""Query left_support_mp yearly averages from right_wing_motions."""
rows = con.execute("""
SELECT year, AVG(left_support_mp), COUNT(*)
FROM right_wing_motions
WHERE classified = TRUE AND left_support_mp IS NOT NULL
GROUP BY year ORDER BY year
""").fetchall()
result: dict[int, dict] = {}
for year, avg, n in rows:
year = int(year)
result[year] = {"mean_left_support": avg, "n": n}
return result
def yearly_summary(yearly: dict[int, dict]) -> dict[int, dict]:
"""Compute mean values from raw lists."""
summary: dict[int, dict] = {}
for year, d in yearly.items():
s: dict[str, Any] = {}
for key in ["centrist_support", "right_support", "left_opposition", "extremity"]:
for key in ["centrist_support_strict", "center_right_support", "right_support", "left_opposition", "extremity"]:
vals = [v for v in d.get(key, []) if not (isinstance(v, float) and np.isnan(v))]
s[f"mean_{key}"] = np.mean(vals) if vals else float("nan")
passes = [p for p in d.get("passed", []) if p is not None]
s["pass_rate"] = sum(passes) / len(passes) if passes else float("nan")
s["n"] = len(d.get("motion_ids", d.get("centrist_support", [])))
s["n"] = len(d.get("motion_ids", d.get("centrist_support_strict", [])))
summary[year] = s
return summary
@ -486,16 +505,18 @@ def create_figure_1(
colour_non_mig = "#4CAF50"
colour_baseline = "#9E9E9E"
ax.plot(years_arr, _vals(yearly_sum, "mean_centrist_support"),
ax.plot(years_arr, _vals(yearly_sum, "mean_centrist_support_strict"),
marker="o", color=colour_rw, linewidth=2, label="All right-wing", zorder=5)
ax.plot(years_arr, _vals(opp_sum, "mean_centrist_support"),
ax.plot(years_arr, _vals(opp_sum, "mean_centrist_support_strict"),
marker="s", color=colour_opp, linewidth=1.5, linestyle="--", label="Opposition-only", zorder=4)
ax.plot(years_arr, _vals(mig_sum, "mean_centrist_support"),
ax.plot(years_arr, _vals(mig_sum, "mean_centrist_support_strict"),
marker="^", color=colour_mig, linewidth=1.5, linestyle=":", label="Migration", zorder=3)
ax.plot(years_arr, _vals(non_mig_sum, "mean_centrist_support"),
ax.plot(years_arr, _vals(non_mig_sum, "mean_centrist_support_strict"),
marker="v", color=colour_non_mig, linewidth=1.5, linestyle="-.", label="Non-migration", zorder=2)
ax.plot(years_arr, _vals(baseline_sum, "mean_centrist_support"),
color=colour_baseline, linewidth=1, linestyle="dashed", alpha=0.7, zorder=1, label="All motions (baseline)")
ax.plot(years_arr, _vals(yearly_sum, "mean_center_right_support"),
marker="D", color="#FF8F00", linewidth=1.5, linestyle="--", label="Center-right (VVD/BBB)", zorder=3)
ax.axvline(x=BREAK_YEAR - 0.5, color="black", linestyle=":", alpha=0.5, linewidth=1)
ax.annotate("2024", xy=(BREAK_YEAR - 0.3, ax.get_ylim()[1] * 0.95 if ax.get_ylim()[1] > 0 else 0.95),
@ -506,8 +527,8 @@ def create_figure_1(
bbox=dict(boxstyle="round", facecolor="white", alpha=0.8))
ax.set_xlabel("Year")
ax.set_ylabel("Centrist support (fraction of parties)")
ax.set_title("Centrist Support for Right-Wing Motions Over Time", fontweight="bold")
ax.set_ylabel("Centrist support (strict — fraction of parties)")
ax.set_title("Centrist Support (Strict) for Right-Wing Motions Over Time", fontweight="bold")
ax.legend(loc="lower right", fontsize=8, ncol=2)
ax.set_ylim(0, 1.05)
ax.grid(True, alpha=0.3)
@ -594,10 +615,10 @@ def create_figure_2(
pre_means_a = np.array(pre_means)
post_means_a = np.array(post_means)
pre_lower = pre_means_a - np.array(pre_p25s)
pre_upper = np.array(pre_p75s) - pre_means_a
post_lower = post_means_a - np.array(post_p25s)
post_upper = np.array(post_p75s) - post_means_a
pre_lower = np.maximum(pre_means_a - np.array(pre_p25s), 0)
pre_upper = np.maximum(np.array(pre_p75s) - pre_means_a, 0)
post_lower = np.maximum(post_means_a - np.array(post_p25s), 0)
post_upper = np.maximum(np.array(post_p75s) - post_means_a, 0)
pre_yerr = np.vstack([pre_lower, pre_upper])
post_yerr = np.vstack([post_lower, post_upper])
@ -616,7 +637,7 @@ def create_figure_2(
f"N={n}", ha="center", va="bottom", fontsize=8, fontweight="bold")
overall_cs_mean = np.average(
_vals(yearly_sum, "mean_centrist_support"),
_vals(yearly_sum, "mean_centrist_support_strict"),
weights=_vals(yearly_sum, "n"),
)
ax2.axhline(y=overall_cs_mean, color="grey", linestyle="--", alpha=0.7, linewidth=1,
@ -638,6 +659,47 @@ def create_figure_2(
return path
def create_figure_3(
left_yearly: dict[int, dict],
) -> str:
"""Figure 3: Left-party support for right-wing motions (bar chart)."""
years = sorted(left_yearly.keys())
years_arr = np.array(years)
means = np.array([left_yearly[y]["mean_left_support"] for y in years])
ns = np.array([left_yearly[y]["n"] for y in years])
# Weighted all-years mean
overall_mean = np.average(means, weights=ns) if ns.sum() > 0 else 0.0
fig, ax = plt.subplots(figsize=(12, 6))
bars = ax.bar(years_arr, means, color="#1565C0", edgecolor="white", alpha=0.9)
for bar, n in zip(bars, ns):
ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 0.005,
f"N={int(n)}", ha="center", va="bottom", fontsize=8)
ax.axhline(y=overall_mean, color="#D32F2F", linestyle="--", alpha=0.8, linewidth=1,
label=f"Weighted mean ({overall_mean:.3f})")
ax.axvline(x=BREAK_YEAR - 0.5, color="black", linestyle=":", alpha=0.5, linewidth=1)
ax.annotate("2024", xy=(BREAK_YEAR - 0.3, ax.get_ylim()[1] * 0.95),
fontsize=9, color="black", alpha=0.7)
ax.set_xlabel("Year")
ax.set_ylabel("Mean left_support_mp")
ax.set_title("Left-wing party support for right-wing motions", fontweight="bold")
ax.legend(fontsize=9)
ax.set_xticks(years_arr)
ax.set_xticklabels([str(y) for y in years], rotation=45)
ax.grid(True, alpha=0.3, axis="y")
plt.tight_layout()
path = str(REPORTS_DIR / "breakpoint_figure_3.png")
fig.savefig(path, dpi=150, bbox_inches="tight")
plt.close(fig)
logger.info("Saved Figure 3 to %s", path)
return path
def generate_report(
yearly_sum: dict[int, dict],
opp_sum: dict[int, dict],
@ -647,8 +709,10 @@ def generate_report(
ext_stratified: dict[str, dict[str, list]],
yearly_raw: dict[int, dict],
opp_raw: dict[int, dict],
left_yearly: dict[int, dict],
fig1_path: str,
fig2_path: str,
fig3_path: str,
audit_sample: list[dict],
audit_notes: str = "",
) -> str:
@ -674,8 +738,8 @@ def generate_report(
opp_post_ext = []
for y, d in yearly_raw.items():
for idx in range(len(d.get("centrist_support", []))):
cs = d["centrist_support"][idx]
for idx in range(len(d.get("centrist_support_strict", []))):
cs = d["centrist_support_strict"][idx]
ext = d["extremity"][idx]
if not (isinstance(cs, float) and np.isnan(cs)):
if y < BREAK_YEAR:
@ -689,8 +753,8 @@ def generate_report(
rw_post_ext.append(ext)
for y, d in opp_raw.items():
for idx in range(len(d.get("centrist_support", []))):
cs = d["centrist_support"][idx]
for idx in range(len(d.get("centrist_support_strict", []))):
cs = d["centrist_support_strict"][idx]
ext = d["extremity"][idx]
if not (isinstance(cs, float) and np.isnan(cs)):
if y < BREAK_YEAR:
@ -710,11 +774,11 @@ def generate_report(
d_opp_ext = cohens_d(np.array(opp_pre_ext), np.array(opp_post_ext)) if opp_pre_ext and opp_post_ext else float("nan")
# Yearly summary table
yearly_table = "| Year | N (RW) | Centrist Support | Extremity | Right Support | Left Opp. |\n"
yearly_table += "|------|--------|-----------------|-----------|---------------|----------|\n"
yearly_table = "| Year | N (RW) | Centrist Support (Strict) | Extremity | Right Support | Left Opp. |\n"
yearly_table += "|------|--------|---------------------------|-----------|---------------|----------|\n"
for y in years:
n = _val(yearly_sum, y, "n")
cs = _val(yearly_sum, y, "mean_centrist_support")
cs = _val(yearly_sum, y, "mean_centrist_support_strict")
ext = _val(yearly_sum, y, "mean_extremity")
rs = _val(yearly_sum, y, "mean_right_support")
lo = _val(yearly_sum, y, "mean_left_opposition")
@ -817,8 +881,8 @@ def generate_report(
]
for domain_name, domain_sum in [("Migration", mig_sum), ("Non-migration", non_mig_sum)]:
pre_cs = np.nanmean([_val(domain_sum, y, "mean_centrist_support") for y in pre_years])
post_cs = np.nanmean([_val(domain_sum, y, "mean_centrist_support") for y in post_years])
pre_cs = np.nanmean([_val(domain_sum, y, "mean_centrist_support_strict") for y in pre_years])
post_cs = np.nanmean([_val(domain_sum, y, "mean_centrist_support_strict") for y in post_years])
lines.append(
f"| {domain_name} | {pre_cs:.3f} | {post_cs:.3f} | {post_cs - pre_cs:+.3f} |"
)
@ -835,19 +899,55 @@ def generate_report(
"If centrist support rose uniformly across all buckets, the shift is about volume",
"(more motions) rather than tolerance. If only the 1-2 bucket rose, right-wing",
"parties filed milder motions post-2024 and the 'shift' is illusory.",
]
# Section 6: Left support for right-wing motions
left_years_sorted = sorted(left_yearly.keys())
left_pre_years_list = [y for y in pre_years if y in left_yearly]
left_post_years_list = [y for y in post_years if y in left_yearly]
left_pre_vals = [left_yearly[y]["mean_left_support"] for y in left_pre_years_list]
left_post_vals = [left_yearly[y]["mean_left_support"] for y in left_post_years_list]
left_pre_mean = np.mean(left_pre_vals) if left_pre_vals else float("nan")
left_post_mean = np.mean(left_post_vals) if left_post_vals else float("nan")
left_delta = left_post_mean - left_pre_mean
left_table = "| Year | N | Mean left_support_mp |\n"
left_table += "|------|---|---------------------|\n"
for y in left_years_sorted:
ls = left_yearly[y]["mean_left_support"]
n = left_yearly[y]["n"]
left_table += f"| {y} | {int(n)} | {ls:.4f} |\n"
lines += [
"",
"## 6. Left-wing support for right-wing motions",
"",
left_table,
"",
f"| Metric | Pre-2024 Mean | Post-2024 Mean | Δ |",
f"|--------|--------------|---------------|-----|",
f"| Left Support (MP) | {left_pre_mean:.4f} | {left_post_mean:.4f} | {left_delta:+.4f} |",
"",
"## 6. Manual Extremity Audit",
f"**Interpretation:** Left parties moved from {left_pre_mean:.1%} to {left_post_mean:.1%} "
f"support — a {abs(left_delta):.1f} point shift. "
"Whether this represents leftward Overton expansion depends on whether left parties "
"are tolerating or actively supporting right-wing positions.",
"",
f"![Figure 3: Left-wing party support for right-wing motions]({Path(fig3_path).name})",
"",
"## 7. Manual Extremity Audit",
"",
audit_notes,
"",
audit_table,
"",
"## 7. Limitations",
"## 8. Limitations",
"",
"- **Small-N time series:** 8 pre-2024 years and at most 3 post-2024 years (2026 is partial).",
" Effect sizes are descriptive, not confirmatory.",
"- **LLM extremity scores:** Content-based, not independently validated beyond the",
" manual audit above. See §6 for agreement rate and noted biases.",
" manual audit above. See §7 for agreement rate and noted biases.",
"- **Coalition composition:** Hardcoded per year. 2024 is ambiguous (Rutte IV until July,",
" Schoof thereafter). Early 2024 motions may be miscoded as Schoof-era.",
"- **Submitter party identification:** Parsed from motion title prefixes (e.g.,",
@ -856,12 +956,13 @@ def generate_report(
"- **Keyword penetration not analyzed:** The right-wing keyword set was derived",
" differentially from right-wing motions, making it circular for adoption analysis.",
"",
"## 8. Figures",
"## 9. Figures",
"",
f"![Figure 1: Centrist Support Over Time]({Path(fig1_path).name})",
f"![Figure 2: Extremity Trends and Stratified Centrist Support]({Path(fig2_path).name})",
f"![Figure 3: Left-wing party support for right-wing motions]({Path(fig3_path).name})",
"",
"## 9. Conclusion",
"## 10. Conclusion",
"",
"*(Fill in after reviewing all indicators and audit results.)*",
]
@ -895,6 +996,9 @@ def main() -> int:
logger.info("Computing extremity-stratified pass rates...")
ext_stratified = compute_extremity_stratified(yearly_raw)
logger.info("Computing left-support yearly averages...")
left_yearly = compute_left_support_yearly(con)
con.close()
yearly_sum = yearly_summary(yearly_raw)
@ -909,6 +1013,9 @@ def main() -> int:
logger.info("Generating Figure 2...")
fig2_path = create_figure_2(yearly_sum, opp_sum, mig_sum, non_mig_sum, ext_stratified)
logger.info("Generating Figure 3...")
fig3_path = create_figure_3(left_yearly)
logger.info("Sampling motions for manual audit...")
audit_sample = sample_audit(yearly_raw)
print_audit(audit_sample)
@ -931,8 +1038,10 @@ def main() -> int:
ext_stratified=ext_stratified,
yearly_raw=yearly_raw,
opp_raw=opp_raw,
left_yearly=left_yearly,
fig1_path=fig1_path,
fig2_path=fig2_path,
fig3_path=fig3_path,
audit_sample=audit_sample,
audit_notes=audit_notes,
)
@ -940,6 +1049,7 @@ def main() -> int:
print(f"\nReport: {report_path}")
print(f"Figure 1: {fig1_path}")
print(f"Figure 2: {fig2_path}")
print(f"Figure 3: {fig3_path}")
return 0

@ -12,19 +12,19 @@ and content extremity for right-wing motions in the Tweede Kamer.
## 1. Yearly Aggregate Metrics (All Right-Wing Motions)
| Year | N (RW) | Centrist Support | Extremity | Right Support | Left Opp. |
|------|--------|-----------------|-----------|---------------|----------|
| 2016 | 6 | 0.722 | 2.00 | 1.000 | 0.708 |
| Year | N (RW) | Centrist Support (Strict) | Extremity | Right Support | Left Opp. |
|------|--------|---------------------------|-----------|---------------|----------|
| 2016 | 6 | 0.667 | 2.00 | 1.000 | 0.708 |
| 2017 | 0 | N/A | N/A | N/A | N/A |
| 2018 | 5 | 1.000 | 1.40 | 0.800 | 0.480 |
| 2019 | 195 | 0.410 | 2.14 | 0.838 | 0.746 |
| 2020 | 469 | 0.326 | 2.26 | 0.818 | 0.758 |
| 2021 | 425 | 0.339 | 2.24 | 0.903 | 0.788 |
| 2022 | 446 | 0.404 | 2.16 | 0.891 | 0.820 |
| 2023 | 365 | 0.457 | 2.24 | 0.900 | 0.821 |
| 2024 | 469 | 0.670 | 1.99 | 0.885 | 0.756 |
| 2025 | 455 | 0.597 | 2.25 | 0.895 | 0.799 |
| 2026 | 151 | 0.518 | 2.33 | 0.916 | 0.834 |
| 2019 | 195 | 0.380 | 2.14 | 0.838 | 0.746 |
| 2020 | 469 | 0.300 | 2.26 | 0.818 | 0.758 |
| 2021 | 425 | 0.175 | 2.24 | 0.903 | 0.788 |
| 2022 | 446 | 0.201 | 2.16 | 0.891 | 0.820 |
| 2023 | 365 | 0.255 | 2.24 | 0.900 | 0.821 |
| 2024 | 469 | 0.595 | 1.99 | 0.885 | 0.756 |
| 2025 | 455 | 0.474 | 2.25 | 0.895 | 0.799 |
| 2026 | 151 | 0.334 | 2.33 | 0.916 | 0.834 |
## 2. Pre/Post 2024 Comparison
@ -35,7 +35,7 @@ and content extremity for right-wing motions in the Tweede Kamer.
| Metric | Pre-2024 Mean | Post-2024 Mean | Δ | Cohen's d |
|--------|--------------|---------------|-----|-----------|
| Centrist Support | 0.384 | 0.618 | +0.234 | +0.68 |
| Centrist Support | 0.251 | 0.507 | +0.256 | +0.65 |
| Extremity | 2.21 | 2.15 | -0.07 | -0.09 |
**Interpretation:** Cohen's d values quantify effect sizes (|d| < 0.2 small, 0.5 medium, > 0.8 large).
@ -45,7 +45,7 @@ These are descriptive, not inferential — with only 8 pre-2024 years and 3 post
| Metric | Pre-2024 Mean | Post-2024 Mean | Δ | Cohen's d | N pre / N post |
|--------|--------------|---------------|-----|-----------|---------------|
| Centrist Support | 0.270 | 0.543 | +0.272 | +0.85 | 1295 / 405 |
| Centrist Support | 0.130 | 0.437 | +0.307 | +0.88 | 1295 / 405 |
| Extremity | 2.28 | 2.18 | -0.10 | -0.14 | 1295 / 405 |
**Interpretation gate:** If opposition metrics also rise post-2024, the shift is not
@ -67,21 +67,21 @@ Migration = category `asiel/vreemdelingen`. Non-migration = all other categories
| Domain | Pre-2024 Mean CS | Post-2024 Mean CS | Δ CS |
|--------|-----------------|------------------|------|
| Migration | 0.303 | 0.536 | +0.233 |
| Non-migration | 0.529 | 0.605 | +0.076 |
| Migration | 0.146 | 0.361 | +0.215 |
| Non-migration | 0.435 | 0.487 | +0.052 |
## 5. Extremity-Stratified Centrist Support
| Bucket | Period | N | Mean CS | Median CS | P25 | P75 |
|--------|--------|---|---------|-----------|---|-----|
| 1-2 (mild) | Pre-2024 | 221 | 0.522 | 0.400 | 0.250 | 1.000 |
| | Post-2024 | 181 | 0.775 | 1.000 | 0.600 | 1.000 |
| 2-3 (moderate) | Pre-2024 | 1205 | 0.403 | 0.250 | 0.000 | 0.750 |
| | Post-2024 | 640 | 0.606 | 0.600 | 0.250 | 1.000 |
| 3-4 (high) | Pre-2024 | 352 | 0.295 | 0.250 | 0.000 | 0.500 |
| | Post-2024 | 175 | 0.565 | 0.600 | 0.250 | 0.800 |
| 4-5 (extreme) | Pre-2024 | 133 | 0.212 | 0.250 | 0.000 | 0.250 |
| | Post-2024 | 79 | 0.474 | 0.500 | 0.250 | 0.800 |
| 1-2 (mild) | Pre-2024 | 221 | 0.422 | 0.500 | 0.000 | 1.000 |
| | Post-2024 | 181 | 0.728 | 1.000 | 0.667 | 1.000 |
| 2-3 (moderate) | Pre-2024 | 1205 | 0.267 | 0.000 | 0.000 | 0.500 |
| | Post-2024 | 640 | 0.497 | 0.500 | 0.000 | 1.000 |
| 3-4 (high) | Pre-2024 | 352 | 0.150 | 0.000 | 0.000 | 0.000 |
| | Post-2024 | 175 | 0.419 | 0.333 | 0.000 | 0.667 |
| 4-5 (extreme) | Pre-2024 | 133 | 0.091 | 0.000 | 0.000 | 0.000 |
| | Post-2024 | 79 | 0.275 | 0.000 | 0.000 | 0.667 |
**Key test:** If centrist support for high-extremity motions (3-5) rose
@ -91,7 +91,31 @@ If centrist support rose uniformly across all buckets, the shift is about volume
(more motions) rather than tolerance. If only the 1-2 bucket rose, right-wing
parties filed milder motions post-2024 and the 'shift' is illusory.
## 6. Manual Extremity Audit
## 6. Left-wing support for right-wing motions
| Year | N | Mean left_support_mp |
|------|---|---------------------|
| 2016 | 6 | 0.2917 |
| 2018 | 5 | 0.5200 |
| 2019 | 195 | 0.2531 |
| 2020 | 469 | 0.2414 |
| 2021 | 425 | 0.2113 |
| 2022 | 446 | 0.1807 |
| 2023 | 365 | 0.1779 |
| 2024 | 469 | 0.2441 |
| 2025 | 455 | 0.2015 |
| 2026 | 151 | 0.1594 |
| Metric | Pre-2024 Mean | Post-2024 Mean | Δ |
|--------|--------------|---------------|-----|
| Left Support (MP) | 0.2680 | 0.2017 | -0.0663 |
**Interpretation:** Left parties moved from 26.8% to 20.2% support — a 0.1 point shift. Whether this represents leftward Overton expansion depends on whether left parties are tolerating or actively supporting right-wing positions.
![Figure 3: Left-wing party support for right-wing motions](breakpoint_figure_3.png)
## 7. Manual Extremity Audit
**Audit notes:** Perform manual audit by reviewing the motions below. Record agreement per motion. Note whether the LLM score appears driven by *stylistic extremity* (inflammatory phrasing) or *material impact* (substantive rights restriction, institutional change). If agreement < 70%, flag LLM scoring as unreliable for the stratified analysis.
@ -119,12 +143,12 @@ parties filed milder motions post-2024 and the 'shift' is illusory.
| 20 | 2019 | sociaal/jeugd | 4 | 4-5 (extreme) | | |
## 7. Limitations
## 8. Limitations
- **Small-N time series:** 8 pre-2024 years and at most 3 post-2024 years (2026 is partial).
Effect sizes are descriptive, not confirmatory.
- **LLM extremity scores:** Content-based, not independently validated beyond the
manual audit above. See §6 for agreement rate and noted biases.
manual audit above. See §7 for agreement rate and noted biases.
- **Coalition composition:** Hardcoded per year. 2024 is ambiguous (Rutte IV until July,
Schoof thereafter). Early 2024 motions may be miscoded as Schoof-era.
- **Submitter party identification:** Parsed from motion title prefixes (e.g.,
@ -133,11 +157,12 @@ parties filed milder motions post-2024 and the 'shift' is illusory.
- **Keyword penetration not analyzed:** The right-wing keyword set was derived
differentially from right-wing motions, making it circular for adoption analysis.
## 8. Figures
## 9. Figures
![Figure 1: Centrist Support Over Time](breakpoint_figure_1.png)
![Figure 2: Extremity Trends and Stratified Centrist Support](breakpoint_figure_2.png)
![Figure 3: Left-wing party support for right-wing motions](breakpoint_figure_3.png)
## 9. Conclusion
## 10. Conclusion
*(Fill in after reviewing all indicators and audit results.)*

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Loading…
Cancel
Save