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. 46
      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 Adds centrist_support_mp, centrist_support_strict, center_right_support,
parties where >=50% of MPs voted voor). This adds centrist_support_mp which and left_support_mp all computed as the fraction of individual MPs
is the fraction of individual centrist MPs who voted voor, weighted by party within each party set who voted 'voor'.
size.
""" """
from __future__ import annotations
import duckdb import sys
from pathlib import Path 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 = 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( 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 = mv.setdefault(party, {"voor": 0, "tegen": 0, "afwezig": 0})
pv[vote] = pv.get(vote, 0) + n pv[vote] = pv.get(vote, 0) + n
# Add column # Add columns if missing
for col_name, _party_set in COLUMNS:
col_check = con.execute( col_check = con.execute(
"SELECT column_name FROM information_schema.columns " "SELECT column_name FROM information_schema.columns "
"WHERE table_name = 'right_wing_motions' AND column_name = 'centrist_support_mp'" "WHERE table_name = 'right_wing_motions' AND column_name = ?",
[col_name],
).fetchone() ).fetchone()
if col_check is None: if col_check is None:
con.execute( con.execute(
"ALTER TABLE right_wing_motions ADD COLUMN centrist_support_mp DOUBLE" f"ALTER TABLE right_wing_motions ADD COLUMN {col_name} DOUBLE"
) )
print("Added centrist_support_mp column") print(f"Added {col_name} column")
# Update rows # Update rows
rows = con.execute( rows = con.execute(
@ -74,10 +93,11 @@ def main(db_path: str = "data/motions.db"):
if votes is None: if votes is None:
skipped += 1 skipped += 1
continue continue
cs_mp = compute_mp_support(votes, CANONICAL_CENTRIST) for col_name, party_set in COLUMNS:
val = compute_mp_support(votes, party_set)
con.execute( con.execute(
"UPDATE right_wing_motions SET centrist_support_mp = ? WHERE motion_id = ?", f"UPDATE right_wing_motions SET {col_name} = ? WHERE motion_id = ?",
[cs_mp, motion_id], [val, motion_id],
) )
updated += 1 updated += 1

@ -100,7 +100,8 @@ def compute_yearly_rw_metrics(con: duckdb.DuckDBPyConnection) -> dict[int, dict]
r.motion_id, r.motion_id,
r.year, r.year,
r.title, r.title,
r.centrist_support, r.centrist_support_strict,
r.center_right_support,
r.right_support, r.right_support,
r.left_opposition, r.left_opposition,
r.category, r.category,
@ -118,7 +119,8 @@ def compute_yearly_rw_metrics(con: duckdb.DuckDBPyConnection) -> dict[int, dict]
yearly: dict[int, dict[str, Any]] = {} yearly: dict[int, dict[str, Any]] = {}
for year in range(YEAR_MIN, YEAR_MAX + 1): for year in range(YEAR_MIN, YEAR_MAX + 1):
yearly[year] = { yearly[year] = {
"centrist_support": [], "centrist_support_strict": [],
"center_right_support": [],
"right_support": [], "right_support": [],
"left_opposition": [], "left_opposition": [],
"extremity": [], "extremity": [],
@ -128,10 +130,11 @@ def compute_yearly_rw_metrics(con: duckdb.DuckDBPyConnection) -> dict[int, dict]
"motion_ids": [], "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: if year is None or year < YEAR_MIN or year > YEAR_MAX:
continue 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]["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]["left_opposition"].append(lo if lo is not None else np.nan)
yearly[year]["extremity"].append(ext if ext is not None else np.nan) yearly[year]["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]] = {} opp: dict[int, dict[str, list]] = {}
for year in range(YEAR_MIN, YEAR_MAX + 1): for year in range(YEAR_MIN, YEAR_MAX + 1):
opp[year] = { opp[year] = {
"centrist_support": [], "centrist_support_strict": [],
"extremity": [], "extremity": [],
"passed": [], "passed": [],
"n": 0, "n": 0,
@ -310,7 +313,7 @@ def compute_opposition_metrics(
if submitter_party in coal: if submitter_party in coal:
continue 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]["extremity"].append(d["extremity"][idx])
opp[year]["passed"].append(d["passed"][idx]) opp[year]["passed"].append(d["passed"][idx])
opp[year]["n"] += 1 opp[year]["n"] += 1
@ -326,14 +329,14 @@ def compute_domain_metrics(
non_mig: dict[int, dict[str, list]] = {} non_mig: dict[int, dict[str, list]] = {}
for year in range(YEAR_MIN, YEAR_MAX + 1): for year in range(YEAR_MIN, YEAR_MAX + 1):
mig[year] = {"centrist_support": [], "extremity": [], "passed": [], "n": 0} mig[year] = {"centrist_support_strict": [], "extremity": [], "passed": [], "n": 0}
non_mig[year] = {"centrist_support": [], "extremity": [], "passed": [], "n": 0} non_mig[year] = {"centrist_support_strict": [], "extremity": [], "passed": [], "n": 0}
for year, d in yearly_raw.items(): for year, d in yearly_raw.items():
for idx in range(len(d["titles"])): for idx in range(len(d["titles"])):
cat = d["categories"][idx] cat = d["categories"][idx]
target = mig if cat == "asiel/vreemdelingen" else non_mig 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]["extremity"].append(d["extremity"][idx])
target[year]["passed"].append(d["passed"][idx]) target[year]["passed"].append(d["passed"][idx])
target[year]["n"] += 1 target[year]["n"] += 1
@ -361,7 +364,7 @@ def compute_extremity_stratified(
period = "pre-2024" if year < BREAK_YEAR else "post-2024" period = "pre-2024" if year < BREAK_YEAR else "post-2024"
for idx in range(len(d["titles"])): for idx in range(len(d["titles"])):
ext = d["extremity"][idx] 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)): if np.isnan(ext) or cs is None or (isinstance(cs, float) and np.isnan(cs)):
continue continue
if ext < 2: if ext < 2:
@ -377,17 +380,33 @@ def compute_extremity_stratified(
return pre_post 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]: def yearly_summary(yearly: dict[int, dict]) -> dict[int, dict]:
"""Compute mean values from raw lists.""" """Compute mean values from raw lists."""
summary: dict[int, dict] = {} summary: dict[int, dict] = {}
for year, d in yearly.items(): for year, d in yearly.items():
s: dict[str, Any] = {} 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))] 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") 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] 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["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 summary[year] = s
return summary return summary
@ -486,16 +505,18 @@ def create_figure_1(
colour_non_mig = "#4CAF50" colour_non_mig = "#4CAF50"
colour_baseline = "#9E9E9E" 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) 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) 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) 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) 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"), 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)") 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.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), 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)) bbox=dict(boxstyle="round", facecolor="white", alpha=0.8))
ax.set_xlabel("Year") ax.set_xlabel("Year")
ax.set_ylabel("Centrist support (fraction of parties)") ax.set_ylabel("Centrist support (strict — fraction of parties)")
ax.set_title("Centrist Support for Right-Wing Motions Over Time", fontweight="bold") ax.set_title("Centrist Support (Strict) for Right-Wing Motions Over Time", fontweight="bold")
ax.legend(loc="lower right", fontsize=8, ncol=2) ax.legend(loc="lower right", fontsize=8, ncol=2)
ax.set_ylim(0, 1.05) ax.set_ylim(0, 1.05)
ax.grid(True, alpha=0.3) ax.grid(True, alpha=0.3)
@ -594,10 +615,10 @@ def create_figure_2(
pre_means_a = np.array(pre_means) pre_means_a = np.array(pre_means)
post_means_a = np.array(post_means) post_means_a = np.array(post_means)
pre_lower = pre_means_a - np.array(pre_p25s) pre_lower = np.maximum(pre_means_a - np.array(pre_p25s), 0)
pre_upper = np.array(pre_p75s) - pre_means_a pre_upper = np.maximum(np.array(pre_p75s) - pre_means_a, 0)
post_lower = post_means_a - np.array(post_p25s) post_lower = np.maximum(post_means_a - np.array(post_p25s), 0)
post_upper = np.array(post_p75s) - post_means_a post_upper = np.maximum(np.array(post_p75s) - post_means_a, 0)
pre_yerr = np.vstack([pre_lower, pre_upper]) pre_yerr = np.vstack([pre_lower, pre_upper])
post_yerr = np.vstack([post_lower, post_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") f"N={n}", ha="center", va="bottom", fontsize=8, fontweight="bold")
overall_cs_mean = np.average( overall_cs_mean = np.average(
_vals(yearly_sum, "mean_centrist_support"), _vals(yearly_sum, "mean_centrist_support_strict"),
weights=_vals(yearly_sum, "n"), weights=_vals(yearly_sum, "n"),
) )
ax2.axhline(y=overall_cs_mean, color="grey", linestyle="--", alpha=0.7, linewidth=1, ax2.axhline(y=overall_cs_mean, color="grey", linestyle="--", alpha=0.7, linewidth=1,
@ -638,6 +659,47 @@ def create_figure_2(
return path 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( def generate_report(
yearly_sum: dict[int, dict], yearly_sum: dict[int, dict],
opp_sum: dict[int, dict], opp_sum: dict[int, dict],
@ -647,8 +709,10 @@ def generate_report(
ext_stratified: dict[str, dict[str, list]], ext_stratified: dict[str, dict[str, list]],
yearly_raw: dict[int, dict], yearly_raw: dict[int, dict],
opp_raw: dict[int, dict], opp_raw: dict[int, dict],
left_yearly: dict[int, dict],
fig1_path: str, fig1_path: str,
fig2_path: str, fig2_path: str,
fig3_path: str,
audit_sample: list[dict], audit_sample: list[dict],
audit_notes: str = "", audit_notes: str = "",
) -> str: ) -> str:
@ -674,8 +738,8 @@ def generate_report(
opp_post_ext = [] opp_post_ext = []
for y, d in yearly_raw.items(): for y, d in yearly_raw.items():
for idx in range(len(d.get("centrist_support", []))): for idx in range(len(d.get("centrist_support_strict", []))):
cs = d["centrist_support"][idx] cs = d["centrist_support_strict"][idx]
ext = d["extremity"][idx] ext = d["extremity"][idx]
if not (isinstance(cs, float) and np.isnan(cs)): if not (isinstance(cs, float) and np.isnan(cs)):
if y < BREAK_YEAR: if y < BREAK_YEAR:
@ -689,8 +753,8 @@ def generate_report(
rw_post_ext.append(ext) rw_post_ext.append(ext)
for y, d in opp_raw.items(): for y, d in opp_raw.items():
for idx in range(len(d.get("centrist_support", []))): for idx in range(len(d.get("centrist_support_strict", []))):
cs = d["centrist_support"][idx] cs = d["centrist_support_strict"][idx]
ext = d["extremity"][idx] ext = d["extremity"][idx]
if not (isinstance(cs, float) and np.isnan(cs)): if not (isinstance(cs, float) and np.isnan(cs)):
if y < BREAK_YEAR: 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") 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 summary table
yearly_table = "| Year | N (RW) | Centrist Support | Extremity | Right Support | Left Opp. |\n" yearly_table = "| Year | N (RW) | Centrist Support (Strict) | Extremity | Right Support | Left Opp. |\n"
yearly_table += "|------|--------|-----------------|-----------|---------------|----------|\n" yearly_table += "|------|--------|---------------------------|-----------|---------------|----------|\n"
for y in years: for y in years:
n = _val(yearly_sum, y, "n") 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") ext = _val(yearly_sum, y, "mean_extremity")
rs = _val(yearly_sum, y, "mean_right_support") rs = _val(yearly_sum, y, "mean_right_support")
lo = _val(yearly_sum, y, "mean_left_opposition") 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)]: 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]) 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") for y in post_years]) post_cs = np.nanmean([_val(domain_sum, y, "mean_centrist_support_strict") for y in post_years])
lines.append( lines.append(
f"| {domain_name} | {pre_cs:.3f} | {post_cs:.3f} | {post_cs - pre_cs:+.3f} |" 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", "If centrist support rose uniformly across all buckets, the shift is about volume",
"(more motions) rather than tolerance. If only the 1-2 bucket rose, right-wing", "(more motions) rather than tolerance. If only the 1-2 bucket rose, right-wing",
"parties filed milder motions post-2024 and the 'shift' is illusory.", "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_notes,
"", "",
audit_table, audit_table,
"", "",
"## 7. Limitations", "## 8. Limitations",
"", "",
"- **Small-N time series:** 8 pre-2024 years and at most 3 post-2024 years (2026 is partial).", "- **Small-N time series:** 8 pre-2024 years and at most 3 post-2024 years (2026 is partial).",
" Effect sizes are descriptive, not confirmatory.", " Effect sizes are descriptive, not confirmatory.",
"- **LLM extremity scores:** Content-based, not independently validated beyond the", "- **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,", "- **Coalition composition:** Hardcoded per year. 2024 is ambiguous (Rutte IV until July,",
" Schoof thereafter). Early 2024 motions may be miscoded as Schoof-era.", " Schoof thereafter). Early 2024 motions may be miscoded as Schoof-era.",
"- **Submitter party identification:** Parsed from motion title prefixes (e.g.,", "- **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", "- **Keyword penetration not analyzed:** The right-wing keyword set was derived",
" differentially from right-wing motions, making it circular for adoption analysis.", " 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 1: Centrist Support Over Time]({Path(fig1_path).name})",
f"![Figure 2: Extremity Trends and Stratified Centrist Support]({Path(fig2_path).name})", f"![Figure 2: 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.)*", "*(Fill in after reviewing all indicators and audit results.)*",
] ]
@ -895,6 +996,9 @@ def main() -> int:
logger.info("Computing extremity-stratified pass rates...") logger.info("Computing extremity-stratified pass rates...")
ext_stratified = compute_extremity_stratified(yearly_raw) ext_stratified = compute_extremity_stratified(yearly_raw)
logger.info("Computing left-support yearly averages...")
left_yearly = compute_left_support_yearly(con)
con.close() con.close()
yearly_sum = yearly_summary(yearly_raw) yearly_sum = yearly_summary(yearly_raw)
@ -909,6 +1013,9 @@ def main() -> int:
logger.info("Generating Figure 2...") logger.info("Generating Figure 2...")
fig2_path = create_figure_2(yearly_sum, opp_sum, mig_sum, non_mig_sum, ext_stratified) 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...") logger.info("Sampling motions for manual audit...")
audit_sample = sample_audit(yearly_raw) audit_sample = sample_audit(yearly_raw)
print_audit(audit_sample) print_audit(audit_sample)
@ -931,8 +1038,10 @@ def main() -> int:
ext_stratified=ext_stratified, ext_stratified=ext_stratified,
yearly_raw=yearly_raw, yearly_raw=yearly_raw,
opp_raw=opp_raw, opp_raw=opp_raw,
left_yearly=left_yearly,
fig1_path=fig1_path, fig1_path=fig1_path,
fig2_path=fig2_path, fig2_path=fig2_path,
fig3_path=fig3_path,
audit_sample=audit_sample, audit_sample=audit_sample,
audit_notes=audit_notes, audit_notes=audit_notes,
) )
@ -940,6 +1049,7 @@ def main() -> int:
print(f"\nReport: {report_path}") print(f"\nReport: {report_path}")
print(f"Figure 1: {fig1_path}") print(f"Figure 1: {fig1_path}")
print(f"Figure 2: {fig2_path}") print(f"Figure 2: {fig2_path}")
print(f"Figure 3: {fig3_path}")
return 0 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) ## 1. Yearly Aggregate Metrics (All Right-Wing Motions)
| Year | N (RW) | Centrist Support | Extremity | Right Support | Left Opp. | | Year | N (RW) | Centrist Support (Strict) | Extremity | Right Support | Left Opp. |
|------|--------|-----------------|-----------|---------------|----------| |------|--------|---------------------------|-----------|---------------|----------|
| 2016 | 6 | 0.722 | 2.00 | 1.000 | 0.708 | | 2016 | 6 | 0.667 | 2.00 | 1.000 | 0.708 |
| 2017 | 0 | N/A | N/A | N/A | N/A | | 2017 | 0 | N/A | N/A | N/A | N/A |
| 2018 | 5 | 1.000 | 1.40 | 0.800 | 0.480 | | 2018 | 5 | 1.000 | 1.40 | 0.800 | 0.480 |
| 2019 | 195 | 0.410 | 2.14 | 0.838 | 0.746 | | 2019 | 195 | 0.380 | 2.14 | 0.838 | 0.746 |
| 2020 | 469 | 0.326 | 2.26 | 0.818 | 0.758 | | 2020 | 469 | 0.300 | 2.26 | 0.818 | 0.758 |
| 2021 | 425 | 0.339 | 2.24 | 0.903 | 0.788 | | 2021 | 425 | 0.175 | 2.24 | 0.903 | 0.788 |
| 2022 | 446 | 0.404 | 2.16 | 0.891 | 0.820 | | 2022 | 446 | 0.201 | 2.16 | 0.891 | 0.820 |
| 2023 | 365 | 0.457 | 2.24 | 0.900 | 0.821 | | 2023 | 365 | 0.255 | 2.24 | 0.900 | 0.821 |
| 2024 | 469 | 0.670 | 1.99 | 0.885 | 0.756 | | 2024 | 469 | 0.595 | 1.99 | 0.885 | 0.756 |
| 2025 | 455 | 0.597 | 2.25 | 0.895 | 0.799 | | 2025 | 455 | 0.474 | 2.25 | 0.895 | 0.799 |
| 2026 | 151 | 0.518 | 2.33 | 0.916 | 0.834 | | 2026 | 151 | 0.334 | 2.33 | 0.916 | 0.834 |
## 2. Pre/Post 2024 Comparison ## 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 | | 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 | | 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). **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 | | 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 | | Extremity | 2.28 | 2.18 | -0.10 | -0.14 | 1295 / 405 |
**Interpretation gate:** If opposition metrics also rise post-2024, the shift is not **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 | | Domain | Pre-2024 Mean CS | Post-2024 Mean CS | Δ CS |
|--------|-----------------|------------------|------| |--------|-----------------|------------------|------|
| Migration | 0.303 | 0.536 | +0.233 | | Migration | 0.146 | 0.361 | +0.215 |
| Non-migration | 0.529 | 0.605 | +0.076 | | Non-migration | 0.435 | 0.487 | +0.052 |
## 5. Extremity-Stratified Centrist Support ## 5. Extremity-Stratified Centrist Support
| Bucket | Period | N | Mean CS | Median CS | P25 | P75 | | Bucket | Period | N | Mean CS | Median CS | P25 | P75 |
|--------|--------|---|---------|-----------|---|-----| |--------|--------|---|---------|-----------|---|-----|
| 1-2 (mild) | Pre-2024 | 221 | 0.522 | 0.400 | 0.250 | 1.000 | | 1-2 (mild) | Pre-2024 | 221 | 0.422 | 0.500 | 0.000 | 1.000 |
| | Post-2024 | 181 | 0.775 | 1.000 | 0.600 | 1.000 | | | Post-2024 | 181 | 0.728 | 1.000 | 0.667 | 1.000 |
| 2-3 (moderate) | Pre-2024 | 1205 | 0.403 | 0.250 | 0.000 | 0.750 | | 2-3 (moderate) | Pre-2024 | 1205 | 0.267 | 0.000 | 0.000 | 0.500 |
| | Post-2024 | 640 | 0.606 | 0.600 | 0.250 | 1.000 | | | Post-2024 | 640 | 0.497 | 0.500 | 0.000 | 1.000 |
| 3-4 (high) | Pre-2024 | 352 | 0.295 | 0.250 | 0.000 | 0.500 | | 3-4 (high) | Pre-2024 | 352 | 0.150 | 0.000 | 0.000 | 0.000 |
| | Post-2024 | 175 | 0.565 | 0.600 | 0.250 | 0.800 | | | Post-2024 | 175 | 0.419 | 0.333 | 0.000 | 0.667 |
| 4-5 (extreme) | Pre-2024 | 133 | 0.212 | 0.250 | 0.000 | 0.250 | | 4-5 (extreme) | Pre-2024 | 133 | 0.091 | 0.000 | 0.000 | 0.000 |
| | Post-2024 | 79 | 0.474 | 0.500 | 0.250 | 0.800 | | | Post-2024 | 79 | 0.275 | 0.000 | 0.000 | 0.667 |
**Key test:** If centrist support for high-extremity motions (3-5) rose **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 (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. 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. **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) | | | | 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). - **Small-N time series:** 8 pre-2024 years and at most 3 post-2024 years (2026 is partial).
Effect sizes are descriptive, not confirmatory. Effect sizes are descriptive, not confirmatory.
- **LLM extremity scores:** Content-based, not independently validated beyond the - **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, - **Coalition composition:** Hardcoded per year. 2024 is ambiguous (Rutte IV until July,
Schoof thereafter). Early 2024 motions may be miscoded as Schoof-era. Schoof thereafter). Early 2024 motions may be miscoded as Schoof-era.
- **Submitter party identification:** Parsed from motion title prefixes (e.g., - **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 - **Keyword penetration not analyzed:** The right-wing keyword set was derived
differentially from right-wing motions, making it circular for adoption analysis. 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 1: Centrist Support Over Time](breakpoint_figure_1.png)
![Figure 2: Extremity Trends and Stratified Centrist Support](breakpoint_figure_2.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.)* *(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