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.
This commit is contained in:
2026-06-07 22:02:04 +02:00
parent a3154f72df
commit 5706e86777
48 changed files with 3755 additions and 1213 deletions
+216
View File
@@ -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())
+272 -23
View File
@@ -46,7 +46,7 @@ def fetch_2d_yearly_data(con: duckdb.DuckDBPyConnection) -> dict[int, dict[str,
"""Join extremity_scores_2d with right_wing_motions to get yearly scores.
Returns dict keyed by year, each containing lists of stylistic, material,
and original text_score values.
and original text_score values, plus gravity-filtered buckets (M>=3, M>=4).
"""
rows = con.execute("""
SELECT
@@ -75,6 +75,10 @@ def fetch_2d_yearly_data(con: duckdb.DuckDBPyConnection) -> dict[int, dict[str,
"non_mig_stijl": [],
"non_mig_materieel": [],
"non_mig_text": [],
"ge3_stijl": [],
"ge3_materieel": [],
"ge4_stijl": [],
"ge4_materieel": [],
}
for year, stijl, materieel, text_score, category in rows:
@@ -93,6 +97,46 @@ def fetch_2d_yearly_data(con: duckdb.DuckDBPyConnection) -> dict[int, dict[str,
yearly[y]["text"].append(float(text_score))
(yearly[y]["mig_text"] if is_mig else yearly[y]["non_mig_text"]).append(float(text_score))
if stijl is not None and materieel is not None:
if float(materieel) >= 3:
yearly[y]["ge3_stijl"].append(float(stijl))
yearly[y]["ge3_materieel"].append(float(materieel))
if float(materieel) >= 4:
yearly[y]["ge4_stijl"].append(float(stijl))
yearly[y]["ge4_materieel"].append(float(materieel))
return yearly
def fetch_all_motion_yearly(con: duckdb.DuckDBPyConnection) -> dict[int, dict[str, list[float]]]:
"""Join extremity_scores_all with motions to get yearly scores for ALL motions.
Returns dict keyed by year, each containing stijl and materieel lists.
"""
logger.info("Fetching all-motion extremity data by year...")
rows = con.execute("""
SELECT
EXTRACT(YEAR FROM m.date) AS year,
esa.stijl_extremiteit,
esa.materiele_impact
FROM extremity_scores_all esa
JOIN motions m ON esa.motion_id = m.id
WHERE m.date IS NOT NULL
AND EXTRACT(YEAR FROM m.date) BETWEEN ? AND ?
ORDER BY year
""", (YEAR_MIN, YEAR_MAX)).fetchall()
yearly: dict[int, dict[str, list[float]]] = {}
for year in range(YEAR_MIN, YEAR_MAX + 1):
yearly[year] = {"stijl": [], "materieel": []}
for year, stijl, materieel in rows:
y = int(year)
yearly[y]["stijl"].append(float(stijl))
yearly[y]["materieel"].append(float(materieel))
total = sum(len(v["stijl"]) for v in yearly.values())
logger.info("Fetched %d all-motion scored motions across %d years", total, len(yearly))
return yearly
@@ -110,9 +154,11 @@ def compute_yearly_summary(
("", ["stijl", "materieel", "text"]),
("mig_", ["mig_stijl", "mig_materieel", "mig_text"]),
("non_mig_", ["non_mig_stijl", "non_mig_materieel", "non_mig_text"]),
("ge3_", ["ge3_stijl", "ge3_materieel"]),
("ge4_", ["ge4_stijl", "ge4_materieel"]),
]:
for key in keys:
short = key.replace("non_mig_", "").replace("mig_", "")
short = key.replace("non_mig_", "").replace("mig_", "").replace("ge3_", "").replace("ge4_", "")
vals = np.array(d.get(key, []))
n = len(vals)
s[f"{prefix}n_{short}"] = n
@@ -187,11 +233,45 @@ def compute_yearly_summary(
s.get("mean_non_mig_stijl") is not None and not np.isnan(s.get("mean_non_mig_stijl", float("nan"))):
s["gap_non_mig"] = s["mean_non_mig_materieel"] - s["mean_non_mig_stijl"]
# Gravity gaps
s["gap_ge3"] = float("nan")
if s.get("ge3_mean_materieel") is not None and not np.isnan(s.get("ge3_mean_materieel", float("nan"))) and \
s.get("ge3_mean_stijl") is not None and not np.isnan(s.get("ge3_mean_stijl", float("nan"))):
s["gap_ge3"] = s["ge3_mean_materieel"] - s["ge3_mean_stijl"]
s["gap_ge4"] = float("nan")
if s.get("ge4_mean_materieel") is not None and not np.isnan(s.get("ge4_mean_materieel", float("nan"))) and \
s.get("ge4_mean_stijl") is not None and not np.isnan(s.get("ge4_mean_stijl", float("nan"))):
s["gap_ge4"] = s["ge4_mean_materieel"] - s["ge4_mean_stijl"]
summary[year] = s
return summary
def compute_all_motion_summary(
yearly: dict[int, dict[str, list[float]]],
) -> dict[int, dict[str, Any]]:
"""Compute simple yearly means for all-motion data (no stratification)."""
summary: dict[int, dict[str, Any]] = {}
for year, d in yearly.items():
s: dict[str, Any] = {"year": year}
for key in ["stijl", "materieel"]:
vals = np.array(d.get(key, []))
n = len(vals)
s[f"n_{key}"] = n
if n > 0:
s[f"mean_{key}"] = float(np.mean(vals))
s[f"std_{key}"] = float(np.std(vals, ddof=1)) if n > 1 else 0.0
s[f"sem_{key}"] = float(np.std(vals, ddof=1) / np.sqrt(n)) if n > 1 else 0.0
else:
s[f"mean_{key}"] = float("nan")
s[f"std_{key}"] = float("nan")
s[f"sem_{key}"] = float("nan")
summary[year] = s
return summary
def compute_divergence_test(
yearly: dict[int, dict[str, list[float]]],
) -> dict[str, Any]:
@@ -286,14 +366,20 @@ def compute_temporal_correlations(summary: dict[int, dict[str, Any]]) -> dict[st
return result
def create_figure(summary: dict[int, dict[str, Any]]) -> str:
"""Generate the 2D extremity temporal figure with 3 panels."""
def create_figure(
summary: dict[int, dict[str, Any]],
all_summary: dict[int, dict[str, Any]],
) -> str:
"""Generate the 2D extremity temporal figure with 4 panels."""
years = sorted(summary.keys())
years_arr = np.array(years)
def _val(yr, key):
return summary[yr].get(key, float("nan"))
def _all_val(yr, key):
return all_summary[yr].get(key, float("nan")) if yr in all_summary else float("nan")
stijl_means = np.array([_val(y, "mean_stijl") for y in years])
mat_means = np.array([_val(y, "mean_materieel") for y in years])
text_means = np.array([_val(y, "mean_text") for y in years])
@@ -315,13 +401,25 @@ def create_figure(summary: dict[int, dict[str, Any]]) -> str:
rs = np.array([_val(y, "r_stijl_mat") for y in years])
ns = np.array([_val(y, "n_stijl") for y in years])
fig, (ax1, ax2, ax3) = plt.subplots(3, 1, figsize=(14, 14), sharex=True)
# Gravity data
ge3_stijl = np.array([_val(y, "ge3_mean_stijl") for y in years])
ge3_mat = np.array([_val(y, "ge3_mean_materieel") for y in years])
ge4_stijl = np.array([_val(y, "ge4_mean_stijl") for y in years])
ge4_mat = np.array([_val(y, "ge4_mean_materieel") for y in years])
# All-motion data
all_stijl = np.array([_all_val(y, "mean_stijl") for y in years])
all_mat = np.array([_all_val(y, "mean_materieel") for y in years])
fig, (ax1, ax2, ax3, ax4) = plt.subplots(4, 1, figsize=(14, 18), sharex=True)
colour_stijl = "#E53935"
colour_mat = "#1E88E5"
colour_text = "#9E9E9E"
colour_ge3 = "#F9A825"
colour_ge4 = "#E65100"
# Panel 1: Yearly means with CIs
# Panel 1: Yearly means with CIs + gravity-weighted trends
mask_stijl = ~np.isnan(stijl_means)
mask_mat = ~np.isnan(mat_means)
mask_text = ~np.isnan(text_means)
@@ -342,13 +440,26 @@ def create_figure(summary: dict[int, dict[str, Any]]) -> str:
)
ax1.plot(years_arr[mask_stijl], stijl_means[mask_stijl],
marker="o", color=colour_stijl, linewidth=2, label="Stylistic extremity")
marker="o", color=colour_stijl, linewidth=2, label="Stylistic extremity (all RW)")
ax1.plot(years_arr[mask_mat], mat_means[mask_mat],
marker="s", color=colour_mat, linewidth=2, label="Material impact")
marker="s", color=colour_mat, linewidth=2, label="Material impact (all RW)")
ax1.plot(years_arr[mask_text], text_means[mask_text],
marker="^", color=colour_text, linewidth=1.5, linestyle="--", alpha=0.7,
label="Original single-score")
# Gravity-weighted lines on Panel 1
mask_ge3_stijl = ~np.isnan(ge3_stijl)
mask_ge3_mat = ~np.isnan(ge3_mat)
mask_ge4_stijl = ~np.isnan(ge4_stijl)
mask_ge4_mat = ~np.isnan(ge4_mat)
ax1.plot(years_arr[mask_ge3_mat], ge3_mat[mask_ge3_mat],
marker="s", color=colour_ge3, linewidth=1.5, linestyle="--", alpha=0.8,
label="Material impact (M≥3)")
ax1.plot(years_arr[mask_ge4_mat], ge4_mat[mask_ge4_mat],
marker="s", color=colour_ge4, linewidth=1.5, linestyle=":", alpha=0.8,
label="Material impact (M≥4)")
ax1.axvline(x=BREAK_YEAR - 0.5, color="black", linestyle=":", alpha=0.5, linewidth=1)
ax1.annotate("2024", xy=(BREAK_YEAR - 0.3, ax1.get_ylim()[1] * 0.95),
fontsize=9, color="black", alpha=0.7)
@@ -399,17 +510,43 @@ def create_figure(summary: dict[int, dict[str, Any]]) -> str:
ax3.annotate(f"r={r_val:.2f}\nn={int(n_val)}", xy=(xi, r_val),
fontsize=7, ha="center", va="bottom", color="#4A148C")
ax3.set_xlabel("Year")
ax3.set_ylabel("Pearson r (stijl, materieel)")
ax3.set_title("Per-Year Correlation: Stylistic vs Material Impact", fontweight="bold")
ax3.grid(True, alpha=0.3, axis="y")
# Panel 4: All-motion vs right-wing comparison
mask_all_stijl = ~np.isnan(all_stijl)
mask_all_mat = ~np.isnan(all_mat)
ax4.plot(years_arr[mask_stijl], stijl_means[mask_stijl],
marker="o", color=colour_stijl, linewidth=2, label="RW Stylistic")
ax4.plot(years_arr[mask_mat], mat_means[mask_mat],
marker="s", color=colour_mat, linewidth=2, label="RW Material")
ax4.plot(years_arr[mask_all_stijl], all_stijl[mask_all_stijl],
marker="o", color=colour_stijl, linewidth=1.5, linestyle="--", alpha=0.6,
label="All-motion Stylistic")
ax4.plot(years_arr[mask_all_mat], all_mat[mask_all_mat],
marker="s", color=colour_mat, linewidth=1.5, linestyle="--", alpha=0.6,
label="All-motion Material")
ax4.axvline(x=BREAK_YEAR - 0.5, color="black", linestyle=":", alpha=0.5, linewidth=1)
ax4.annotate("2024", xy=(BREAK_YEAR - 0.3, ax4.get_ylim()[1] * 0.95),
fontsize=9, color="black", alpha=0.7)
ax4.set_xlabel("Year")
ax4.set_ylabel("Mean score (1-5 scale)")
ax4.set_title("All-Motion vs Right-Wing: Stylistic and Material Extremity", fontweight="bold")
ax4.legend(loc="upper left", fontsize=8)
ax4.grid(True, alpha=0.3)
ax1.set_xticks(years_arr)
ax2.set_xticks(years_arr)
ax3.set_xticks(years_arr)
ax3.set_xticklabels([str(y) for y in years], rotation=45)
ax4.set_xticks(years_arr)
ax4.set_xticklabels([str(y) for y in years], rotation=45)
ax1.tick_params(labelbottom=False)
ax2.tick_params(labelbottom=False)
ax3.tick_params(labelbottom=False)
plt.tight_layout()
path = str(REPORTS_DIR / "extremity_2d_temporal_figure.png")
@@ -425,6 +562,7 @@ def generate_report(
temporal_corr: dict[str, Any],
yearly: dict[int, dict[str, list[float]]],
fig_path: str,
all_summary: dict[int, dict[str, Any]],
) -> str:
"""Write the markdown report."""
years = sorted(summary.keys())
@@ -518,6 +656,32 @@ def generate_report(
mig_r, mig_p = pearsonr(all_mig_stijl, all_mig_mat) if len(all_mig_stijl) >= 3 else (float("nan"), float("nan"))
nm_r, nm_p = pearsonr(all_nm_stijl, all_nm_mat) if len(all_nm_stijl) >= 3 else (float("nan"), float("nan"))
# Gravity-weighted means (pre/post)
def pre_post_all_gap(key):
pre = [all_summary[y].get(key, float("nan")) for y in pre_years if y in all_summary]
post = [all_summary[y].get(key, float("nan")) for y in post_years if y in all_summary]
pre_valid = [v for v in pre if not np.isnan(v)]
post_valid = [v for v in post if not np.isnan(v)]
return (np.mean(pre_valid) if pre_valid else float("nan"),
np.mean(post_valid) if post_valid else float("nan"))
pre_all_stijl, post_all_stijl = pre_post_all_gap("mean_stijl")
pre_all_mat, post_all_mat = pre_post_all_gap("mean_materieel")
# Gravity-weighted means for right-wing
def pre_post_ge(key):
pre = [summary[y].get(key, float("nan")) for y in pre_years]
post = [summary[y].get(key, float("nan")) for y in post_years]
pre_valid = [v for v in pre if not np.isnan(v)]
post_valid = [v for v in post if not np.isnan(v)]
return (np.mean(pre_valid) if pre_valid else float("nan"),
np.mean(post_valid) if post_valid else float("nan"))
pre_ge3_stijl, post_ge3_stijl = pre_post_ge("ge3_mean_stijl")
pre_ge3_mat, post_ge3_mat = pre_post_ge("ge3_mean_materieel")
pre_ge4_stijl, post_ge4_stijl = pre_post_ge("ge4_mean_stijl")
pre_ge4_mat, post_ge4_mat = pre_post_ge("ge4_mean_materieel")
lines = [
"# 2D Extremity Temporal Decomposition",
"",
@@ -525,7 +689,8 @@ def generate_report(
"when stylistic and material extremity scores are analyzed separately over time.",
"",
"**Analysis period:** 2016-2026",
"**Data source:** `extremity_scores_2d` (2,869 motions scored) joined with `right_wing_motions`",
"**Data source (right-wing):** `extremity_scores_2d` (2,869 motions scored) joined with `right_wing_motions`",
"**Data source (all motions):** `extremity_scores_all` (29,570 motions scored) joined with `motions`",
"**Domains:** Migration = `asiel/vreemdelingen`; Non-migration = all other categories",
"",
"> *Years with <50 scored motions are flagged for low confidence.",
@@ -642,10 +807,13 @@ def generate_report(
]
for domain_name, prefix in [("Migration", "mig_"), ("Non-migration", "non_mig_")]:
pre_s = np.nanmean([summary[y].get(f"{prefix}mean_stijl", float("nan")) for y in pre_years])
pre_m = np.nanmean([summary[y].get(f"{prefix}mean_materieel", float("nan")) for y in pre_years])
post_s = np.nanmean([summary[y].get(f"{prefix}mean_stijl", float("nan")) for y in post_years])
post_m = np.nanmean([summary[y].get(f"{prefix}mean_materieel", float("nan")) for y in post_years])
def _nanmean(vals):
valid = [v for v in vals if not np.isnan(v)]
return float(np.mean(valid)) if valid else float("nan")
pre_s = _nanmean([summary[y].get(f"{prefix}mean_stijl", float("nan")) for y in pre_years])
pre_m = _nanmean([summary[y].get(f"{prefix}mean_materieel", float("nan")) for y in pre_years])
post_s = _nanmean([summary[y].get(f"{prefix}mean_stijl", float("nan")) for y in post_years])
post_m = _nanmean([summary[y].get(f"{prefix}mean_materieel", float("nan")) for y in post_years])
pre_g = pre_m - pre_s if not np.isnan(pre_s) and not np.isnan(pre_m) else float("nan")
post_g = post_m - post_s if not np.isnan(post_s) and not np.isnan(post_m) else float("nan")
@@ -663,23 +831,60 @@ def generate_report(
"",
"---",
"",
"## 9. Figure",
"## 9. Gravity-Weighted Trends (Right-Wing)",
"",
"Yearly means for right-wing motions filtered by material impact thresholds.",
"M≥3 = motions with substantive material impact (score ≥ 3).",
"M≥4 = motions with fundamental material impact (score ≥ 4).",
"",
"| Year | N (all RW) | M≥3 N | M≥4 N | Stijl (all) | Stijl M≥3 | Stijl M≥4 | Mat (all) | Mat M≥3 | Mat M≥4 |",
"|------|-----------|-------|-------|-------------|-----------|-----------|-----------|---------|---------|",
]
for y in years:
s = summary[y]
lines.append(
f"| {y} "
f"| {int(s.get('n_stijl', 0))} "
f"| {int(s.get('ge3_n_stijl', 0))} "
f"| {int(s.get('ge4_n_stijl', 0))} "
f"| {fmt(s.get('mean_stijl'))} "
f"| {fmt(s.get('ge3_mean_stijl'))} "
f"| {fmt(s.get('ge4_mean_stijl'))} "
f"| {fmt(s.get('mean_materieel'))} "
f"| {fmt(s.get('ge3_mean_materieel'))} "
f"| {fmt(s.get('ge4_mean_materieel'))} |"
)
lines += [
"",
"| Bucket | Pre-2024 Mean Stijl | Pre-2024 Mean Mat | Post-2024 Mean Stijl | Post-2024 Mean Mat |",
"|--------|-------------------|-------------------|---------------------|-------------------|",
f"| All RW | {fmt(pre_stijl)} | {fmt(pre_mat)} | {fmt(post_stijl)} | {fmt(post_mat)} |",
f"| M≥3 | {fmt(pre_ge3_stijl)} | {fmt(pre_ge3_mat)} | {fmt(post_ge3_stijl)} | {fmt(post_ge3_mat)} |",
f"| M≥4 | {fmt(pre_ge4_stijl)} | {fmt(pre_ge4_mat)} | {fmt(post_ge4_stijl)} | {fmt(post_ge4_mat)} |",
"",
"---",
"",
"## 10. Figure",
"",
f"![2D Extremity Temporal Figure]({Path(fig_path).name})",
"",
"**Figure panels:**",
"- **Top panel:** Yearly mean stylistic (red) and material (blue) extremity scores with",
" 95% bootstrap confidence intervals. Grey dashed line = original single-dimension",
" `text_score` for comparison.",
"- **Middle panel:** Gap trajectory (material minus stylistic) for all domains, migration,",
" `text_score` for comparison. Gold/orange lines show material impact for M≥3 and M≥4 subsets.",
"- **Second panel:** Gap trajectory (material minus stylistic) for all domains, migration,",
" and non-migration. Positive gap = material impact exceeds stylistic extremity.",
" A widening gap indicates increasing divergence between dimensions.",
"- **Bottom panel:** Per-year Pearson correlation between stylistic and material scores.",
"- **Third panel:** Per-year Pearson correlation between stylistic and material scores.",
" Declining correlation over time suggests the two dimensions are decoupling.",
"- **Fourth panel:** All-motion (dashed) vs right-wing (solid) comparison for both stylistic",
" and material dimensions. Shows how right-wing trends compare to the full motion landscape.",
"",
"---",
"",
"## 10. Limitations",
"## 11. Limitations",
"",
"- **Yearly resolution:** Year-level aggregation necessarily smooths within-year trends.",
" The quarterly framework from U1 provides finer resolution for other metrics.",
@@ -694,7 +899,45 @@ def generate_report(
"",
"---",
"",
"## 11. Conclusion",
"## 12. All-Motion Comparison",
"",
"Yearly means for ALL motions (from `extremity_scores_all`) compared to right-wing-only means.",
"This provides context for whether right-wing trends reflect party-specific dynamics or broader",
"parliamentary trends.",
"",
"| Year | N (all) | All Stijl | All Mat | N (RW) | RW Stijl | RW Mat | Diff Stijl | Diff Mat |",
"|------|---------|-----------|---------|--------|----------|--------|------------|----------|",
]
for y in years:
s = summary[y]
a = all_summary.get(y, {})
all_n = int(a.get("n_stijl", 0))
all_s = fmt(a.get("mean_stijl"))
all_m = fmt(a.get("mean_materieel"))
rw_n = int(s.get("n_stijl", 0))
rw_s = fmt(s.get("mean_stijl"))
rw_m = fmt(s.get("mean_materieel"))
diff_s = fmt(s.get("mean_stijl", float("nan")) - a.get("mean_stijl", float("nan")) if not np.isnan(s.get("mean_stijl", float("nan"))) and not np.isnan(a.get("mean_stijl", float("nan"))) else float("nan"))
diff_m = fmt(s.get("mean_materieel", float("nan")) - a.get("mean_materieel", float("nan")) if not np.isnan(s.get("mean_materieel", float("nan"))) and not np.isnan(a.get("mean_materieel", float("nan"))) else float("nan"))
lines.append(
f"| {y} | {all_n} | {all_s} | {all_m} | {rw_n} | {rw_s} | {rw_m} | {diff_s} | {diff_m} |"
)
# Pre/post for all-motion
lines += [
"",
"| Period | All Stijl | All Mat | RW Stijl | RW Mat | Stijl Δ | Mat Δ |",
"|--------|-----------|---------|----------|--------|---------|-------|",
f"| Pre-2024 | {fmt(pre_all_stijl)} | {fmt(pre_all_mat)} | {fmt(pre_stijl)} | {fmt(pre_mat)} | {fmt(pre_stijl - pre_all_stijl if not np.isnan(pre_stijl) and not np.isnan(pre_all_stijl) else float('nan'))} | {fmt(pre_mat - pre_all_mat if not np.isnan(pre_mat) and not np.isnan(pre_all_mat) else float('nan'))} |",
f"| Post-2024 | {fmt(post_all_stijl)} | {fmt(post_all_mat)} | {fmt(post_stijl)} | {fmt(post_mat)} | {fmt(post_stijl - post_all_stijl if not np.isnan(post_stijl) and not np.isnan(post_all_stijl) else float('nan'))} | {fmt(post_mat - post_all_mat if not np.isnan(post_mat) and not np.isnan(post_all_mat) else float('nan'))} |",
"",
]
lines += [
"---",
"",
"## 13. Conclusion",
"",
f"The overall stijl-materieel correlation is r={fmt(overall_r)} (p={fmt(overall_p, 6)}),",
"consistent with the aggregate finding of r≈0.47.",
@@ -727,11 +970,17 @@ def main() -> int:
total_motions = sum(len(yearly[y]["stijl"]) for y in yearly)
logger.info("Fetched %d scored motions across %d years", total_motions, len(yearly))
logger.info("Fetching all-motion extremity data...")
all_yearly = fetch_all_motion_yearly(con)
con.close()
logger.info("Computing yearly summary statistics...")
summary = compute_yearly_summary(yearly)
logger.info("Computing all-motion yearly summary...")
all_summary = compute_all_motion_summary(all_yearly)
logger.info("Running divergence test (Wilcoxon)...")
divergence = compute_divergence_test(yearly)
@@ -739,10 +988,10 @@ def main() -> int:
temporal_corr = compute_temporal_correlations(summary)
logger.info("Generating figure...")
fig_path = create_figure(summary)
fig_path = create_figure(summary, all_summary)
logger.info("Generating report...")
report_path = generate_report(summary, divergence, temporal_corr, yearly, fig_path)
report_path = generate_report(summary, divergence, temporal_corr, yearly, fig_path, all_summary)
print(f"\nReport: {report_path}")
print(f"Figure: {fig_path}")
+179
View File
@@ -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())
@@ -1,9 +1,10 @@
#!/usr/bin/env python3
"""U2: Quantify the 2024 Overton Window breakpoint in Dutch parliament.
Descriptive analysis of centrist support, pass rates, and content extremity
for right-wing motions — with coalition control via opposition-only filtering,
domain decomposition, and a baseline comparison.
Descriptive analysis of centrist support, pass rates, and 2D extremity
(stijl_extremiteit / materiele_impact) for right-wing motions — with coalition
control via opposition-only filtering, domain decomposition, gravity-controlled
analysis, and all-motion baseline comparison.
Usage:
uv run python analysis/right_wing/overton_breakpoint_analysis.py
@@ -12,6 +13,8 @@ Output:
reports/overton_window/breakpoint_analysis.md
reports/overton_window/breakpoint_figure_1.png
reports/overton_window/breakpoint_figure_2.png
reports/overton_window/breakpoint_figure_3.png
reports/overton_window/breakpoint_figure_4.png
"""
from __future__ import annotations
@@ -52,6 +55,7 @@ CANONICAL_CENTRIST_SET = set(CANONICAL_CENTRIST)
EXTREMITY_BUCKET_ORDER = ["1-2 (mild)", "2-3 (moderate)", "3-4 (high)", "4-5 (extreme)"]
def _extremity_bucket(score: float) -> str:
if score < 2:
return "1-2 (mild)"
@@ -62,17 +66,15 @@ def _extremity_bucket(score: float) -> str:
else:
return "4-5 (extreme)"
CANONICAL_LEFT_SET = set(CANONICAL_LEFT)
CANONICAL_RIGHT_SET = set(CANONICAL_RIGHT)
def compute_yearly_rw_metrics(con: duckdb.DuckDBPyConnection) -> dict[int, dict]:
"""Yearly aggregates for classified right-wing motions.
Joins right_wing_motions with extremity_scores and motions (for pass rate).
Joins right_wing_motions with extremity_scores_2d (2D extremity) and motions.
"""
rows = con.execute("""
SELECT
@@ -84,16 +86,17 @@ def compute_yearly_rw_metrics(con: duckdb.DuckDBPyConnection) -> dict[int, dict]
r.right_support,
r.left_opposition,
r.category,
e.text_score AS extremity_score,
e2d.stijl_extremiteit,
e2d.materiele_impact,
m.voting_results,
m.winning_margin,
m.date
FROM right_wing_motions r
JOIN extremity_scores e ON r.motion_id = e.motion_id
JOIN extremity_scores_2d e2d ON r.motion_id = e2d.motion_id
JOIN motions m ON r.motion_id = m.id
WHERE r.classified = TRUE
AND r.year IS NOT NULL
AND e.text_score IS NOT NULL
AND e2d.materiele_impact IS NOT NULL
""").fetchall()
yearly: dict[int, dict[str, Any]] = {}
@@ -103,7 +106,8 @@ def compute_yearly_rw_metrics(con: duckdb.DuckDBPyConnection) -> dict[int, dict]
"center_right_support": [],
"right_support": [],
"left_opposition": [],
"extremity": [],
"stijl_extremiteit": [],
"materiele_impact": [],
"passed": [],
"categories": [],
"titles": [],
@@ -111,14 +115,15 @@ def compute_yearly_rw_metrics(con: duckdb.DuckDBPyConnection) -> dict[int, dict]
"dates": [],
}
for mid, year, title, cst, crs, rs, lo, cat, ext, vr_json, wm, motion_date in rows:
for mid, year, title, cst, crs, rs, lo, cat, stijl, mat, vr_json, wm, motion_date in rows:
if year is None or year < YEAR_MIN or year > YEAR_MAX:
continue
yearly[year]["centrist_support_strict"].append(cst if cst is not None else np.nan)
yearly[year]["center_right_support"].append(crs if crs is not None else np.nan)
yearly[year]["right_support"].append(rs if rs is not None else np.nan)
yearly[year]["left_opposition"].append(lo if lo is not None else np.nan)
yearly[year]["extremity"].append(ext if ext is not None else np.nan)
yearly[year]["stijl_extremiteit"].append(stijl if stijl is not None else np.nan)
yearly[year]["materiele_impact"].append(mat if mat is not None else np.nan)
yearly[year]["categories"].append(cat or "other")
yearly[year]["titles"].append(title or "")
yearly[year]["motion_ids"].append(mid)
@@ -134,6 +139,83 @@ def compute_yearly_rw_metrics(con: duckdb.DuckDBPyConnection) -> dict[int, dict]
return yearly
def compute_gravity_controlled_cs(con: duckdb.DuckDBPyConnection) -> dict[int, dict[str, list[float]]]:
"""Centrist support for right-wing motions stratified by materiele_impact level.
Returns dict mapping materiele_impact (1-5) to pre/post 2024 CS lists.
"""
rows = con.execute("""
SELECT
r.motion_id,
r.year,
r.centrist_support_strict,
e2d.materiele_impact
FROM right_wing_motions r
JOIN extremity_scores_2d e2d ON r.motion_id = e2d.motion_id
WHERE r.classified = TRUE
AND r.year IS NOT NULL
AND e2d.materiele_impact IS NOT NULL
AND r.centrist_support_strict IS NOT NULL
""").fetchall()
result: dict[int, dict[str, list[float]]] = {}
for _mid, year, cs, m in rows:
year_int = int(year)
m_int = int(m)
period = "pre-2024" if year_int < BREAK_YEAR else "post-2024"
result.setdefault(m_int, {"pre-2024": [], "post-2024": []})
result[m_int][period].append(float(cs))
return result
def compute_all_motion_comparison(con: duckdb.DuckDBPyConnection) -> dict[str, list[float]]:
"""Centrist support for motions NOT in right_wing_motions (classified=TRUE), pre/post 2024."""
rw_ids = set(
row[0] for row in con.execute(
"SELECT motion_id FROM right_wing_motions WHERE classified = TRUE"
).fetchall()
)
rows = con.execute("""
SELECT
mv.motion_id,
EXTRACT(YEAR FROM mv.date) AS year,
mv.party,
COUNT(*) AS n,
mv.vote
FROM mp_votes mv
WHERE mv.party IS NOT NULL
AND mv.date IS NOT NULL
GROUP BY mv.motion_id, EXTRACT(YEAR FROM mv.date), mv.party, mv.vote
""").fetchall()
motion_party_votes: dict[int, dict[str, dict[str, int]]] = {}
motion_year_map: dict[int, int] = {}
for mid, year, party, n, vote in rows:
year_int = int(year)
if year_int < YEAR_MIN or year_int > YEAR_MAX:
continue
if mid in rw_ids:
continue
mv = motion_party_votes.setdefault(mid, {})
pv = mv.setdefault(party, {"voor": 0, "tegen": 0, "afwezig": 0})
pv[vote] = pv.get(vote, 0) + n
motion_year_map[mid] = year_int
result: dict[str, list[float]] = {"pre-2024": [], "post-2024": []}
for mid, votes in motion_party_votes.items():
year_int = motion_year_map.get(mid)
if year_int is None:
continue
cs = _support_ratio(votes, CANONICAL_CENTRIST_SET)
if cs is not None:
period = "pre-2024" if year_int < BREAK_YEAR else "post-2024"
result[period].append(cs)
return result
def compute_yearly_baseline(con: duckdb.DuckDBPyConnection) -> dict[int, dict]:
"""Baseline: centrist support across ALL motions (not just RW)."""
yearly: dict[int, dict] = {}
@@ -156,21 +238,21 @@ def compute_yearly_baseline(con: duckdb.DuckDBPyConnection) -> dict[int, dict]:
motion_party_votes: dict[int, dict[str, dict[str, int]]] = {}
motion_year_map: dict[int, int] = {}
for mid, year, party, n, vote in centrist_rows:
year = int(year)
if year < YEAR_MIN or year > YEAR_MAX:
year_int = int(year)
if year_int < YEAR_MIN or year_int > YEAR_MAX:
continue
mv = motion_party_votes.setdefault(mid, {})
pv = mv.setdefault(party, {"voor": 0, "tegen": 0, "afwezig": 0})
pv[vote] = pv.get(vote, 0) + n
motion_year_map[mid] = year
motion_year_map[mid] = year_int
for mid, votes in motion_party_votes.items():
year = motion_year_map.get(mid)
if year is None:
year_int = motion_year_map.get(mid)
if year_int is None:
continue
cs = _support_ratio(votes, CANONICAL_CENTRIST_SET)
if cs is not None:
yearly[year]["centrist_support"].append(cs)
yearly[year_int]["centrist_support"].append(cs)
return yearly
@@ -219,7 +301,7 @@ def compute_opposition_metrics(
for year in range(YEAR_MIN, YEAR_MAX + 1):
opp[year] = {
"centrist_support_strict": [],
"extremity": [],
"materiele_impact": [],
"passed": [],
"n": 0,
}
@@ -246,7 +328,7 @@ def compute_opposition_metrics(
continue
opp[year]["centrist_support_strict"].append(d["centrist_support_strict"][idx])
opp[year]["extremity"].append(d["extremity"][idx])
opp[year]["materiele_impact"].append(d["materiele_impact"][idx])
opp[year]["passed"].append(d["passed"][idx])
opp[year]["n"] += 1
@@ -261,15 +343,15 @@ def compute_domain_metrics(
non_mig: dict[int, dict[str, list]] = {}
for year in range(YEAR_MIN, YEAR_MAX + 1):
mig[year] = {"centrist_support_strict": [], "extremity": [], "passed": [], "n": 0}
non_mig[year] = {"centrist_support_strict": [], "extremity": [], "passed": [], "n": 0}
mig[year] = {"centrist_support_strict": [], "materiele_impact": [], "passed": [], "n": 0}
non_mig[year] = {"centrist_support_strict": [], "materiele_impact": [], "passed": [], "n": 0}
for year, d in yearly_raw.items():
for idx in range(len(d["titles"])):
cat = d["categories"][idx]
target = mig if cat == "asiel/vreemdelingen" else non_mig
target[year]["centrist_support_strict"].append(d["centrist_support_strict"][idx])
target[year]["extremity"].append(d["extremity"][idx])
target[year]["materiele_impact"].append(d["materiele_impact"][idx])
target[year]["passed"].append(d["passed"][idx])
target[year]["n"] += 1
@@ -279,7 +361,7 @@ def compute_domain_metrics(
def compute_extremity_stratified(
yearly_raw: dict[int, dict],
) -> dict[str, dict[str, list]]:
"""Compute centrist_support per extremity bucket, pre vs post 2024."""
"""Centrist_support per materiele_impact bucket, pre vs post 2024."""
pre_post: dict[str, dict[str, list]] = {
"pre-2024": {b: [] for b in EXTREMITY_BUCKET_ORDER},
"post-2024": {b: [] for b in EXTREMITY_BUCKET_ORDER},
@@ -288,11 +370,11 @@ def compute_extremity_stratified(
for year, d in yearly_raw.items():
period = "pre-2024" if year < BREAK_YEAR else "post-2024"
for idx in range(len(d["titles"])):
ext = d["extremity"][idx]
mat = d["materiele_impact"][idx]
cs = d["centrist_support_strict"][idx]
if np.isnan(ext) or cs is None or (isinstance(cs, float) and np.isnan(cs)):
if np.isnan(mat) or cs is None or (isinstance(cs, float) and np.isnan(cs)):
continue
pre_post[period][_extremity_bucket(ext)].append(cs)
pre_post[period][_extremity_bucket(mat)].append(cs)
return pre_post
@@ -308,8 +390,8 @@ def compute_left_support_yearly(con: duckdb.DuckDBPyConnection) -> dict[int, dic
result: dict[int, dict] = {}
for year, avg, n in rows:
year = int(year)
result[year] = {"mean_left_support": avg, "n": n}
year_int = int(year)
result[year_int] = {"mean_left_support": avg, "n": n}
return result
@@ -318,7 +400,8 @@ def yearly_summary(yearly: dict[int, dict]) -> dict[int, dict]:
summary: dict[int, dict] = {}
for year, d in yearly.items():
s: dict[str, Any] = {}
for key in ["centrist_support_strict", "center_right_support", "right_support", "left_opposition", "extremity"]:
for key in ["centrist_support_strict", "center_right_support", "right_support",
"left_opposition", "materiele_impact", "stijl_extremiteit"]:
vals = [v for v in d.get(key, []) if not (isinstance(v, float) and np.isnan(v))]
s[f"mean_{key}"] = np.mean(vals) if vals else float("nan")
passes = [p for p in d.get("passed", []) if p is not None]
@@ -329,22 +412,24 @@ def yearly_summary(yearly: dict[int, dict]) -> dict[int, dict]:
def sample_audit(yearly_raw: dict[int, dict]) -> list[dict]:
"""Stratified random sample: 5 motions per extremity bucket, 20 total."""
"""Stratified random sample: 5 motions per materiele_impact bucket, 20 total."""
bucket_motions: dict[str, list[int]] = {b: [] for b in EXTREMITY_BUCKET_ORDER}
all_motions: list[dict] = []
for year, d in yearly_raw.items():
for idx in range(len(d["titles"])):
ext = d["extremity"][idx]
if np.isnan(ext):
mat = d["materiele_impact"][idx]
stijl = d["stijl_extremiteit"][idx]
if np.isnan(mat):
continue
b = _extremity_bucket(ext)
b = _extremity_bucket(mat)
bucket_motions[b].append(len(all_motions))
all_motions.append({
"year": year,
"title": d["titles"][idx],
"category": d["categories"][idx],
"extremity": ext,
"materiele_impact": mat,
"stijl_extremiteit": stijl,
})
rng = random.Random(42)
@@ -357,18 +442,18 @@ def sample_audit(yearly_raw: dict[int, dict]) -> list[dict]:
m["bucket"] = bucket_name
sampled.append(m)
sampled.sort(key=lambda x: (x["bucket"], x["extremity"]))
sampled.sort(key=lambda x: (x["bucket"], x["materiele_impact"]))
return sampled
def print_audit(sampled: list[dict]) -> None:
"""Display sampled motions for manual extremity audit."""
print("\n" + "=" * 80)
print(" MANUAL EXTREMITY AUDIT")
print(" MANUAL EXTREMITY AUDIT (2D)")
print("=" * 80)
print()
print("For each motion below, judge whether you agree with the LLM-assigned extremity bucket.")
print("Also note: does the score reflect stylistic extremity (language) or material impact (policy)?")
print("For each motion below, judge whether you agree with the LLM-assigned 2D scores.")
print("Stijl = stylistic extremity (language), Materieel = material impact (policy).")
print()
from itertools import groupby
@@ -379,7 +464,7 @@ def print_audit(sampled: list[dict]) -> None:
for i, m in enumerate(group_list, 1):
title = m["title"][:120]
print(f"\n [{i}] Year={m['year']} | Category={m['category']}")
print(f" LLM Score: {m['extremity']}")
print(f" Stijl: {m['stijl_extremiteit']} | Materieel: {m['materiele_impact']}")
print(f" Title: {title}")
print(f" Agree? [Y/N] Driven by: Language / Policy / Both")
@@ -456,7 +541,7 @@ def create_figure_2(
non_mig_sum: dict[int, dict],
ext_stratified: dict[str, dict[str, list]],
) -> str:
"""Figure 2: Extremity over time + Extremity-stratified centrist support (2 panels)."""
"""Figure 2: Material impact over time + Impact-stratified centrist support (2 panels)."""
years = sorted(yearly_sum.keys())
years_arr = np.array(years)
@@ -470,13 +555,13 @@ def create_figure_2(
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 6))
ax1.plot(years_arr, _vals(yearly_sum, "mean_extremity"),
ax1.plot(years_arr, _vals(yearly_sum, "mean_materiele_impact"),
marker="o", color=colour_rw, linewidth=2, label="All right-wing", zorder=5)
ax1.plot(years_arr, _vals(opp_sum, "mean_extremity"),
ax1.plot(years_arr, _vals(opp_sum, "mean_materiele_impact"),
marker="s", color=colour_opp, linewidth=1.5, linestyle="--", label="Opposition-only RW", zorder=4)
ax1.plot(years_arr, _vals(mig_sum, "mean_extremity"),
ax1.plot(years_arr, _vals(mig_sum, "mean_materiele_impact"),
marker="^", color=colour_mig, linewidth=1.5, linestyle=":", label="Migration", zorder=3)
ax1.plot(years_arr, _vals(non_mig_sum, "mean_extremity"),
ax1.plot(years_arr, _vals(non_mig_sum, "mean_materiele_impact"),
marker="v", color=colour_non_mig, linewidth=1.5, linestyle="-.", label="Non-migration", zorder=2)
ax1.axvline(x=BREAK_YEAR - 0.5, color="black", linestyle=":", alpha=0.5, linewidth=1)
@@ -484,8 +569,8 @@ def create_figure_2(
fontsize=9, color="black", alpha=0.7)
ax1.set_xlabel("Year")
ax1.set_ylabel("Mean Extremity Score")
ax1.set_title("Content Extremity Over Time", fontweight="bold")
ax1.set_ylabel("Mean Material Impact (1-5)")
ax1.set_title("Material Impact Over Time", fontweight="bold")
ax1.legend(loc="upper left", fontsize=8)
ax1.grid(True, alpha=0.3)
ax1.set_xticks(years_arr)
@@ -549,7 +634,7 @@ def create_figure_2(
ax2.set_xticks(x)
ax2.set_xticklabels(bucket_labels)
ax2.set_ylabel("Centrist Support")
ax2.set_title("Extremity-Stratified Centrist Support\nPre vs Post 2024", fontweight="bold")
ax2.set_title("Material Impact-Stratified Centrist Support\nPre vs Post 2024", fontweight="bold")
ax2.legend(fontsize=8)
ax2.set_ylim(0, 1.05)
ax2.grid(True, alpha=0.3, axis="y")
@@ -602,6 +687,79 @@ def create_figure_3(
return path
def create_figure_4(
gravity_cs: dict[int, dict[str, list[float]]],
all_motion: dict[str, list[float]],
) -> str:
"""Figure 4: Gravity-controlled centrist support — grouped bar chart.
Pre/post 2024 centrist_support_strict broken down by materiele_impact level (1-5),
with all-motion baseline as comparison bars.
"""
levels = sorted(gravity_cs.keys())
x = np.arange(len(levels))
width = 0.25
pre_means = []
post_means = []
pre_ns = []
post_ns = []
for lvl in levels:
pre_arr = np.array(gravity_cs[lvl]["pre-2024"])
post_arr = np.array(gravity_cs[lvl]["post-2024"])
pre_means.append(np.mean(pre_arr) if len(pre_arr) > 0 else 0)
post_means.append(np.mean(post_arr) if len(post_arr) > 0 else 0)
pre_ns.append(len(pre_arr))
post_ns.append(len(post_arr))
pre_means_a = np.array(pre_means)
post_means_a = np.array(post_means)
rw_pre_mean = np.mean(pre_means_a) if len(pre_means_a) > 0 else 0
rw_post_mean = np.mean(post_means_a) if len(post_means_a) > 0 else 0
nonrw_pre_arr = np.array(all_motion["pre-2024"])
nonrw_post_arr = np.array(all_motion["post-2024"])
nonrw_pre_mean = np.mean(nonrw_pre_arr) if len(nonrw_pre_arr) > 0 else 0
nonrw_post_mean = np.mean(nonrw_post_arr) if len(nonrw_post_arr) > 0 else 0
fig, ax = plt.subplots(figsize=(12, 6))
bars_rw_pre = ax.bar(x - width, pre_means_a, width,
label="Right-wing Pre-2024", color="#90CAF9", edgecolor="black", alpha=0.9)
bars_rw_post = ax.bar(x, post_means_a, width,
label="Right-wing Post-2024", color="#1E88E5", edgecolor="black", alpha=0.9)
bars_baseline_pre = ax.bar(x + width, [nonrw_pre_mean] * len(levels), width,
label=f"Non-RW Pre-2024 ({nonrw_pre_mean:.3f})",
color="#E0E0E0", edgecolor="black", alpha=0.6, hatch="//")
bars_baseline_post = ax.bar(x + 2 * width, [nonrw_post_mean] * len(levels), width,
label=f"Non-RW Post-2024 ({nonrw_post_mean:.3f})",
color="#9E9E9E", edgecolor="black", alpha=0.6, hatch="//")
for bar, n in zip(bars_rw_pre, pre_ns):
ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 0.01,
f"N={n}", ha="center", va="bottom", fontsize=7)
for bar, n in zip(bars_rw_post, post_ns):
ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 0.01,
f"N={n}", ha="center", va="bottom", fontsize=7)
ax.set_xticks(x + width / 2)
ax.set_xticklabels([f"M={lvl}" for lvl in levels])
ax.set_xlabel("Material Impact Level")
ax.set_ylabel("Centrist Support (Strict)")
ax.set_title("Gravity-Controlled Centrist Support\nPre vs Post 2024 by Material Impact", fontweight="bold")
ax.legend(fontsize=7, loc="upper right")
ax.set_ylim(0, 1.05)
ax.grid(True, alpha=0.3, axis="y")
plt.tight_layout()
path = str(REPORTS_DIR / "breakpoint_figure_4.png")
fig.savefig(path, dpi=150, bbox_inches="tight")
plt.close(fig)
logger.info("Saved Figure 4 to %s", path)
return path
def generate_report(
yearly_sum: dict[int, dict],
opp_sum: dict[int, dict],
@@ -612,9 +770,12 @@ def generate_report(
yearly_raw: dict[int, dict],
opp_raw: dict[int, dict],
left_yearly: dict[int, dict],
gravity_cs: dict[int, dict[str, list[float]]],
all_motion: dict[str, list[float]],
fig1_path: str,
fig2_path: str,
fig3_path: str,
fig4_path: str,
audit_sample: list[dict],
audit_notes: str = "",
) -> str:
@@ -629,67 +790,67 @@ def generate_report(
rw_pre_cs = []
rw_post_cs = []
rw_pre_ext = []
rw_post_ext = []
rw_pre_mat = []
rw_post_mat = []
opp_pre_cs = []
opp_post_cs = []
opp_pre_ext = []
opp_post_ext = []
opp_pre_mat = []
opp_post_mat = []
for y, d in yearly_raw.items():
for idx in range(len(d.get("centrist_support_strict", []))):
cs = d["centrist_support_strict"][idx]
ext = d["extremity"][idx]
mat = d["materiele_impact"][idx]
if not (isinstance(cs, float) and np.isnan(cs)):
if y < BREAK_YEAR:
rw_pre_cs.append(cs)
else:
rw_post_cs.append(cs)
if not (isinstance(ext, float) and np.isnan(ext)):
if not (isinstance(mat, float) and np.isnan(mat)):
if y < BREAK_YEAR:
rw_pre_ext.append(ext)
rw_pre_mat.append(mat)
else:
rw_post_ext.append(ext)
rw_post_mat.append(mat)
for y, d in opp_raw.items():
for idx in range(len(d.get("centrist_support_strict", []))):
cs = d["centrist_support_strict"][idx]
ext = d["extremity"][idx]
mat = d["materiele_impact"][idx]
if not (isinstance(cs, float) and np.isnan(cs)):
if y < BREAK_YEAR:
opp_pre_cs.append(cs)
else:
opp_post_cs.append(cs)
if not (isinstance(ext, float) and np.isnan(ext)):
if not (isinstance(mat, float) and np.isnan(mat)):
if y < BREAK_YEAR:
opp_pre_ext.append(ext)
opp_pre_mat.append(mat)
else:
opp_post_ext.append(ext)
opp_post_mat.append(mat)
d_cs = cohens_d(np.array(rw_pre_cs), np.array(rw_post_cs))
d_ext = cohens_d(np.array(rw_pre_ext), np.array(rw_post_ext))
d_mat = cohens_d(np.array(rw_pre_mat), np.array(rw_post_mat))
d_opp_cs = cohens_d(np.array(opp_pre_cs), np.array(opp_post_cs)) if opp_pre_cs and opp_post_cs else float("nan")
d_opp_ext = cohens_d(np.array(opp_pre_ext), np.array(opp_post_ext)) if opp_pre_ext and opp_post_ext else float("nan")
d_opp_mat = cohens_d(np.array(opp_pre_mat), np.array(opp_post_mat)) if opp_pre_mat and opp_post_mat else float("nan")
yearly_table = "| Year | N (RW) | Centrist Support (Strict) | Extremity | Right Support | Left Opp. |\n"
yearly_table += "|------|--------|---------------------------|-----------|---------------|----------|\n"
yearly_table = "| Year | N (RW) | Centrist Support (Strict) | Material Impact | Right Support | Left Opp. |\n"
yearly_table += "|------|--------|---------------------------|----------------|---------------|----------|\n"
for y in years:
n = _val(yearly_sum, y, "n")
cs = _val(yearly_sum, y, "mean_centrist_support_strict")
ext = _val(yearly_sum, y, "mean_extremity")
mat = _val(yearly_sum, y, "mean_materiele_impact")
rs = _val(yearly_sum, y, "mean_right_support")
lo = _val(yearly_sum, y, "mean_left_opposition")
cs_str = f"{cs:.3f}" if not np.isnan(cs) else "N/A"
ext_str = f"{ext:.2f}" if not np.isnan(ext) else "N/A"
mat_str = f"{mat:.2f}" if not np.isnan(mat) else "N/A"
rs_str = f"{rs:.3f}" if not np.isnan(rs) else "N/A"
lo_str = f"{lo:.3f}" if not np.isnan(lo) else "N/A"
yearly_table += f"| {y} | {int(n)} | {cs_str} | {ext_str} | {rs_str} | {lo_str} |\n"
yearly_table += f"| {y} | {int(n)} | {cs_str} | {mat_str} | {rs_str} | {lo_str} |\n"
bucket_order = EXTREMITY_BUCKET_ORDER
ext_table = "| Bucket | Period | N | Mean CS | Median CS | P25 | P75 |\n"
ext_table += "|--------|--------|---|---------|-----------|---|-----|\n"
ext_table = "| Bucket (Material Impact) | Period | N | Mean CS | Median CS | P25 | P75 |\n"
ext_table += "|--------------------------|--------|---|---------|-----------|---|-----|\n"
for b in bucket_order:
pre_arr = np.array(ext_stratified["pre-2024"].get(b, []))
post_arr = np.array(ext_stratified["post-2024"].get(b, []))
@@ -713,13 +874,41 @@ def generate_report(
f"{pt_p25:.3f} | {pt_p75:.3f} |\n"
)
audit_table = "| # | Year | Category | LLM Score | Bucket | Agreed? | Driver |\n"
audit_table += "|---|------|----------|-----------|--------|---------|--------|\n"
gravity_rows = []
for lvl in sorted(gravity_cs.keys()):
pre_arr = np.array(gravity_cs[lvl]["pre-2024"])
post_arr = np.array(gravity_cs[lvl]["post-2024"])
n_pre = len(pre_arr)
n_post = len(post_arr)
pre_mean = np.mean(pre_arr) if n_pre > 0 else float("nan")
post_mean = np.mean(post_arr) if n_post > 0 else float("nan")
delta = post_mean - pre_mean
gravity_rows.append((lvl, pre_mean, post_mean, delta, n_pre, n_post))
gravity_table = "| Material Impact Level | Pre-2024 Mean CS | Post-2024 Mean CS | Δ | N pre | N post |\n"
gravity_table += "|----------------------|-----------------|------------------|-----|-------|--------|\n"
for lvl, pre_m, post_m, delta, n_pre, n_post in gravity_rows:
gravity_table += f"| M={lvl} | {pre_m:.3f} | {post_m:.3f} | {delta:+.3f} | {n_pre} | {n_post} |\n"
nonrw_pre_arr = np.array(all_motion["pre-2024"])
nonrw_post_arr = np.array(all_motion["post-2024"])
nonrw_pre_mean = np.mean(nonrw_pre_arr) if len(nonrw_pre_arr) > 0 else float("nan")
nonrw_post_mean = np.mean(nonrw_post_arr) if len(nonrw_post_arr) > 0 else float("nan")
nonrw_delta = nonrw_post_mean - nonrw_pre_mean
rw_overall_pre = np.mean(rw_pre_cs) if rw_pre_cs else float("nan")
rw_overall_post = np.mean(rw_post_cs) if rw_post_cs else float("nan")
audit_table = "| # | Year | Category | Stijl | Materieel | Bucket | Agreed? | Driver |\n"
audit_table += "|---|------|----------|-------|-----------|--------|---------|--------|\n"
for i, m in enumerate(audit_sample, 1):
audit_table += f"| {i} | {m['year']} | {m['category']} | {m['extremity']} | {m['bucket']} | | |\n"
audit_table += (
f"| {i} | {m['year']} | {m['category']} | {m['stijl_extremiteit']} "
f"| {m['materiele_impact']} | {m['bucket']} | | |\n"
)
lines = [
"# Overton Window Breakpoint Analysis",
"# Overton Window Breakpoint Analysis (2D Extremity)",
"",
"**Goal:** Quantify the 2024 structural break in centrist support",
"and content extremity for right-wing motions in the Tweede Kamer.",
@@ -729,6 +918,10 @@ def generate_report(
"**Centrist parties:** VVD, D66, CDA, NSC, BBB, CU",
"**Left parties:** PvdA, GL, SP, PvdD, Volt, DENK, Bij1",
"",
"**2D Extremity dimensions:**",
"- **Materiële Impact** (material): substantive policy impact (rights restriction, institutional change)",
"- **Stijl** (stylistic): inflammatory phrasing, rhetorical extremity",
"",
"---",
"",
"## 1. Yearly Aggregate Metrics (All Right-Wing Motions)",
@@ -744,7 +937,7 @@ def generate_report(
f"| Metric | Pre-2024 Mean | Post-2024 Mean | Δ | Cohen's d |",
f"|--------|--------------|---------------|-----|-----------|",
f"| Centrist Support | {np.mean(rw_pre_cs):.3f} | {np.mean(rw_post_cs):.3f} | {np.mean(rw_post_cs) - np.mean(rw_pre_cs):+.3f} | {d_cs:+.2f} |",
f"| Extremity | {np.mean(rw_pre_ext):.2f} | {np.mean(rw_post_ext):.2f} | {np.mean(rw_post_ext) - np.mean(rw_pre_ext):+.2f} | {d_ext:+.2f} |",
f"| Material Impact | {np.mean(rw_pre_mat):.2f} | {np.mean(rw_post_mat):.2f} | {np.mean(rw_post_mat) - np.mean(rw_pre_mat):+.2f} | {d_mat:+.2f} |",
"",
f"**Interpretation:** Cohen's d values quantify effect sizes (|d| < 0.2 small, 0.5 medium, > 0.8 large).",
f"These are descriptive, not inferential — with only {len(pre_years)} pre-2024 years and {len(post_years)} post-2024 years, statistical significance is not claimed.",
@@ -754,7 +947,7 @@ def generate_report(
f"| Metric | Pre-2024 Mean | Post-2024 Mean | Δ | Cohen's d | N pre / N post |",
f"|--------|--------------|---------------|-----|-----------|---------------|",
f"| Centrist Support | {np.mean(opp_pre_cs):.3f} | {np.mean(opp_post_cs):.3f} | {np.mean(opp_post_cs) - np.mean(opp_pre_cs):+.3f} | {d_opp_cs:+.2f} | {len(opp_pre_cs)} / {len(opp_post_cs)} |",
f"| Extremity | {np.mean(opp_pre_ext):.2f} | {np.mean(opp_post_ext):.2f} | {np.mean(opp_post_ext) - np.mean(opp_pre_ext):+.2f} | {d_opp_ext:+.2f} | {len(opp_pre_ext)} / {len(opp_post_ext)} |",
f"| Material Impact | {np.mean(opp_pre_mat):.2f} | {np.mean(opp_post_mat):.2f} | {np.mean(opp_post_mat) - np.mean(opp_pre_mat):+.2f} | {d_opp_mat:+.2f} | {len(opp_pre_mat)} / {len(opp_post_mat)} |",
"",
"**Interpretation gate:** If opposition metrics also rise post-2024, the shift is not",
"purely coalition-driven. If opposition metrics stay flat while overall metrics rise,",
@@ -786,16 +979,43 @@ def generate_report(
lines += [
"",
"## 5. Extremity-Stratified Centrist Support",
"## 5. Material Impact-Stratified Centrist Support",
"",
ext_table,
"",
"**Key test:** If centrist support for high-extremity motions (3-5) rose",
"**Key test:** If centrist support for high-impact motions (M=3-5) rose",
"disproportionately post-2024 while centrist support for mild motions stayed flat,",
"centrists are more tolerant of extreme content — direct Overton shift evidence.",
"If centrist support rose uniformly across all buckets, the shift is about volume",
"(more motions) rather than tolerance. If only the 1-2 bucket rose, right-wing",
"(more motions) rather than tolerance. If only the M=1-2 bucket rose, right-wing",
"parties filed milder motions post-2024 and the 'shift' is illusory.",
"",
"## 6. Gravity-Controlled Centrist Support",
"",
"Centrist support for right-wing motions, stratified by materiele_impact level,",
"measured as fraction of centrist parties (VVD, D66, CDA, NSC, BBB, CU) voting 'voor'.",
"",
gravity_table,
"",
"**Interpretation:** This gravity-controlled analysis shows whether the post-2024",
"centrist support shift is uniform across all levels of material impact or",
"concentrated in specific impact tiers. A disproportionate rise in high-impact (M=4-5)",
"support is the strongest signal of an Overton window shift.",
"",
"## 7. All-Motion Baseline Comparison",
"",
"Centrist support for right-wing motions vs non-right-wing motions, pre/post 2024.",
"Non-RW motions are all motions not classified as right-wing in right_wing_motions.",
"",
f"| Group | Pre-2024 Mean CS | Post-2024 Mean CS | Δ | N pre | N post |",
f"|------|-----------------|------------------|-----|-------|--------|",
f"| Right-wing | {rw_overall_pre:.3f} | {rw_overall_post:.3f} | {rw_overall_post - rw_overall_pre:+.3f} | {len(rw_pre_cs)} | {len(rw_post_cs)} |",
f"| Non-right-wing | {nonrw_pre_mean:.3f} | {nonrw_post_mean:.3f} | {nonrw_delta:+.3f} | {len(nonrw_pre_arr)} | {len(nonrw_post_arr)} |",
"",
"**Interpretation:** If right-wing CS rose significantly more than non-right-wing CS,",
"the shift is specific to right-wing content and not a general parliamentary trend.",
"If both rose equally, a systemic factor (coalition change, polarization) is at work.",
"",
]
left_years_sorted = sorted(left_yearly.keys())
@@ -817,7 +1037,7 @@ def generate_report(
lines += [
"",
"## 6. Left-wing support for right-wing motions",
"## 8. Left-wing support for right-wing motions",
"",
left_table,
"",
@@ -832,18 +1052,18 @@ def generate_report(
"",
f"![Figure 3: Left-wing party support for right-wing motions]({Path(fig3_path).name})",
"",
"## 7. Manual Extremity Audit",
"## 9. Manual Extremity Audit",
"",
audit_notes,
"",
audit_table,
"",
"## 8. Limitations",
"## 10. Limitations",
"",
"- **Small-N time series:** 8 pre-2024 years and at most 3 post-2024 years (2026 is partial).",
" Effect sizes are descriptive, not confirmatory.",
"- **LLM extremity scores:** Content-based, not independently validated beyond the",
" manual audit above. See §7 for agreement rate and noted biases.",
" manual audit above. See §9 for agreement rate and noted biases.",
"- **Coalition composition:** Hardcoded per year. 2024 is ambiguous (Rutte IV until July,",
" Schoof thereafter). Early 2024 motions may be miscoded as Schoof-era.",
"- **Submitter party identification:** Parsed from motion title prefixes (e.g.,",
@@ -852,13 +1072,14 @@ def generate_report(
"- **Keyword penetration not analyzed:** The right-wing keyword set was derived",
" differentially from right-wing motions, making it circular for adoption analysis.",
"",
"## 9. Figures",
"## 11. Figures",
"",
f"![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: Material Impact Trends and Stratified Centrist Support]({Path(fig2_path).name})",
f"![Figure 3: Left-wing party support for right-wing motions]({Path(fig3_path).name})",
f"![Figure 4: Gravity-Controlled Centrist Support by Material Impact]({Path(fig4_path).name})",
"",
"## 10. Conclusion",
"## 12. Conclusion",
"",
"*(Fill in after reviewing all indicators and audit results.)*",
]
@@ -895,6 +1116,12 @@ def main() -> int:
logger.info("Computing left-support yearly averages...")
left_yearly = compute_left_support_yearly(con)
logger.info("Computing gravity-controlled centrist support...")
gravity_cs = compute_gravity_controlled_cs(con)
logger.info("Computing all-motion baseline comparison...")
all_motion = compute_all_motion_comparison(con)
con.close()
yearly_sum = yearly_summary(yearly_raw)
@@ -912,6 +1139,9 @@ def main() -> int:
logger.info("Generating Figure 3...")
fig3_path = create_figure_3(left_yearly)
logger.info("Generating Figure 4 (gravity-controlled)...")
fig4_path = create_figure_4(gravity_cs, all_motion)
logger.info("Sampling motions for manual audit...")
audit_sample = sample_audit(yearly_raw)
print_audit(audit_sample)
@@ -919,7 +1149,7 @@ def main() -> int:
logger.info("Generating report...")
audit_notes = (
"**Audit notes:** Perform manual audit by reviewing the motions below. "
"Record agreement per motion. Note whether the LLM score appears driven by "
"Record agreement per motion. Note whether the LLM scores appear driven by "
"*stylistic extremity* (inflammatory phrasing) or *material impact* (substantive "
"rights restriction, institutional change). "
"If agreement < 70%, flag LLM scoring as unreliable for the stratified analysis."
@@ -935,9 +1165,12 @@ def main() -> int:
yearly_raw=yearly_raw,
opp_raw=opp_raw,
left_yearly=left_yearly,
gravity_cs=gravity_cs,
all_motion=all_motion,
fig1_path=fig1_path,
fig2_path=fig2_path,
fig3_path=fig3_path,
fig4_path=fig4_path,
audit_sample=audit_sample,
audit_notes=audit_notes,
)
@@ -946,6 +1179,7 @@ def main() -> int:
print(f"Figure 1: {fig1_path}")
print(f"Figure 2: {fig2_path}")
print(f"Figure 3: {fig3_path}")
print(f"Figure 4: {fig4_path}")
return 0
+5 -3
View File
@@ -125,11 +125,13 @@ def load_model_data(
"is_opposition": is_opposition,
})
# Filter to rows with valid category and submitter_party in right-wing set
valid_records = []
for r in records:
if r["category"] is None:
continue
r["category"] = "overig"
# Filter to rows with valid submitter_party in right-wing set
valid_records = []
for r in records:
if r["submitter_party"] is None:
continue
if r["submitter_party"] not in RIGHT_WING_PARTIES: