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:
@@ -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())
|
||||
@@ -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".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}")
|
||||
|
||||
@@ -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".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".name})",
|
||||
f".name})",
|
||||
f".name})",
|
||||
f".name})",
|
||||
f".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
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user