diff --git a/analysis/right_wing/migrate_mp_level_metrics.py b/analysis/right_wing/migrate_mp_level_metrics.py index fc81e62..9d93722 100644 --- a/analysis/right_wing/migrate_mp_level_metrics.py +++ b/analysis/right_wing/migrate_mp_level_metrics.py @@ -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() diff --git a/analysis/right_wing/overton_breakpoint_analysis.py b/analysis/right_wing/overton_breakpoint_analysis.py index 5c5d85e..a5e4cbb 100644 --- a/analysis/right_wing/overton_breakpoint_analysis.py +++ b/analysis/right_wing/overton_breakpoint_analysis.py @@ -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 diff --git a/reports/overton_window/breakpoint_analysis.md b/reports/overton_window/breakpoint_analysis.md index 723eec5..42f8da4 100644 --- a/reports/overton_window/breakpoint_analysis.md +++ b/reports/overton_window/breakpoint_analysis.md @@ -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.)* \ No newline at end of file diff --git a/reports/overton_window/breakpoint_figure_1.png b/reports/overton_window/breakpoint_figure_1.png index 15126fa..756a71c 100644 Binary files a/reports/overton_window/breakpoint_figure_1.png and b/reports/overton_window/breakpoint_figure_1.png differ diff --git a/reports/overton_window/breakpoint_figure_3.png b/reports/overton_window/breakpoint_figure_3.png new file mode 100644 index 0000000..f1786d7 Binary files /dev/null and b/reports/overton_window/breakpoint_figure_3.png differ