#!/usr/bin/env python3 """Systematic mechanism classification of right-wing motions. Classifies a stratified sample of 200 motions across 10 mechanism types to validate the consensus framing hypothesis. Performs chi-squared tests and generates a markdown report. Usage: uv run python analysis/right_wing/mechanism_classification.py uv run python analysis/right_wing/mechanism_classification.py --n-pre-high 25 --n-pre-low 25 --n-post-high 75 --n-post-low 75 """ from __future__ import annotations import argparse import json import sys from collections import Counter from pathlib import Path from typing import Any import duckdb import numpy as np from scipy.stats import chi2_contingency ROOT = Path(__file__).parent.parent.parent.resolve() if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) # ── mechanism taxonomy ─────────────────────────────────────────────────────── MECHANISMS = [ "consensus_framing", "institutional_rule_of_law", "welfare_service_expansion", "procedural_technical", "local_constituency", "coalition_alignment", "symbolic_declaratory", "targeted_restriction", "system_dismantling", "crisis_response", ] MECHANISM_LABELS_NL = { "consensus_framing": "Consensus framing (gedeeld belang)", "institutional_rule_of_law": "Institutioneel/rechtsstatelijk", "welfare_service_expansion": "Welzijn/dienstverlening uitbreiding", "procedural_technical": "Procedureel/technisch", "local_constituency": "Lokaal/regionaal", "coalition_alignment": "Coalitie-afstemming", "symbolic_declaratory": "Symbolisch/declaratoir", "targeted_restriction": "Gerichte restrictie", "system_dismantling": "Systeemontmanteling", "crisis_response": "Crisisrespons", } # ── inline classifications (subagent-classified) ───────────────────────────── # Classification key: motion_id -> mechanism # Classified by reading full title + body_text of each motion. CLASSIFICATIONS: dict[int, str] = { # === PRE_HIGH (25 motions, pre-2024, centrist_support_strict > 0.5) === 15458: "crisis_response", # corona tax deferral/bureaucracy 26477: "institutional_rule_of_law", # Israel SOFA treaty ratification 9149: "consensus_framing", # arming MQ-9 Reaper (shared defense) 17099: "procedural_technical", # Brexit transition law amendment 4933: "procedural_technical", # soil amendment to Environment Act 17751: "consensus_framing", # zero baseline regulatory burden 20068: "procedural_technical", # baseline measurement manure policy 16520: "consensus_framing", # Dutch agriculture global leadership 17036: "welfare_service_expansion", # defense work guarantee scheme 17681: "consensus_framing", # simplify car taxation 14554: "procedural_technical", # tourism cooperation quartermaster 21864: "procedural_technical", # adapt manure processing definition 26493: "targeted_restriction", # crackdown on asylum seeker nuisance 21982: "consensus_framing", # MKB regulatory burden reduction 14125: "crisis_response", # minimize corona tax bureaucracy 13683: "welfare_service_expansion", # GLB influence on farmer income 16691: "procedural_technical", # wild boar population management 15005: "procedural_technical", # periodic franchise consultation body 17536: "institutional_rule_of_law", # tackle hate preachers across Schengen 16999: "consensus_framing", # prevent unfair steel competition 8325: "procedural_technical", # defense materiel budget amendment 13370: "welfare_service_expansion", # PGB equal position amendment 18030: "procedural_technical", # highway lighting at night 11382: "procedural_technical", # amendment removing generic exemption 18616: "procedural_technical", # VAT e-commerce implementation law # === PRE_LOW (25 motions, pre-2024, centrist_support_strict <= 0.5) === 12411: "crisis_response", # temporary nitrogen threshold for housing 22595: "crisis_response", # shopping by appointment during lockdown 15772: "system_dismantling", # prevent pension cuts (challenge ECB rate) 7111: "welfare_service_expansion", # max support for fishing sector 25784: "targeted_restriction", # keep coal plants open until nuclear ready 27731: "system_dismantling", # BOR tax amendment (dismantle tax change) 15626: "crisis_response", # corona kickstart economy scenarios 20215: "welfare_service_expansion", # protect high-quality farmland 16430: "symbolic_declaratory", # don't send 45bn to southern EU states 25982: "local_constituency", # prevent cold sanition shrimp fishery 17176: "targeted_restriction", # criminalize illegal residence 7054: "procedural_technical", # stacking effect of housing market measures 20323: "procedural_technical", # optical recognition for catch registration 18025: "system_dismantling", # halt curriculum revision PO/VO 14837: "system_dismantling", # nature policy without nitrogen fixation 19620: "targeted_restriction", # natural gas-free housing never mandatory 21801: "consensus_framing", # embrace Defense Vision 2035 19464: "crisis_response", # keep terraces open during EK football 26855: "targeted_restriction", # limit immigration inflow 22280: "local_constituency", # farmer costs for societal tasks 20115: "symbolic_declaratory", # defend national veto rights in EU 15082: "targeted_restriction", # no residency permits for delayed procedures 6637: "targeted_restriction", # protect welfare state via asylum stop 18691: "symbolic_declaratory", # no extra troops to Afghanistan 18062: "crisis_response", # apologies for care home corona deaths # === POST_HIGH (75 motions, post-2024, centrist_support_strict > 0.5) === 3784: "procedural_technical", # healthcare fraud info sharing 10205: "procedural_technical", # defense materiel fund budget 2025 10278: "coalition_alignment", # budget amendment covering OCW package 25079: "consensus_framing", # EU nitrogen standards for industry 2980: "targeted_restriction", # designate NL as under migration pressure 10420: "crisis_response", # citizen resilience / preparedness info 25092: "targeted_restriction", # Ukrainian displaced persons pay care costs 25545: "institutional_rule_of_law", # legal basis for housing corp data 23065: "procedural_technical", # Justice & Security budget 2024 2878: "welfare_service_expansion", # index Wbso tax scheme for R&D 25573: "procedural_technical", # efficient spending nature subsidies 3298: "symbolic_declaratory", # support Gaza peace plan 25061: "consensus_framing", # simplify RI&E obligations for SMEs 4481: "consensus_framing", # acquire control points (geo-)economic policy 3961: "procedural_technical", # nuclear fleet & synergy study 473: "institutional_rule_of_law", # recover UvA riot damages from demonstrators 10413: "consensus_framing", # max legal room for drone training 974: "procedural_technical", # WLC norm impact on housing ambition 24009: "procedural_technical", # scientific basis for spray zones 9789: "institutional_rule_of_law", # use temporary law on terrorism measures 24651: "targeted_restriction", # slow labor migration via top summit 1890: "local_constituency", # Groningen/Noord-Drenthe success stories 1191: "consensus_framing", # prioritize safety in Station Agenda 3448: "targeted_restriction", # reserve nitrogen space for PAS melders 23910: "institutional_rule_of_law", # legal options vs antisemitic organizations 25566: "welfare_service_expansion", # childminder childcare allowance fix 2070: "targeted_restriction", # return plan vs uncooperative countries 23885: "consensus_framing", # pension funds focus on purchasing power 24906: "procedural_technical", # repair technical omissions Succession Act 2496: "procedural_technical", # satellite launch capacity Netherlands 25582: "targeted_restriction", # stricter asylum permit withdrawal 3053: "local_constituency", # safety campus Assen development 1495: "procedural_technical", # risk-based foreign funding oversight 10178: "procedural_technical", # Economic Affairs budget 2025 1614: "procedural_technical", # nuclear sector training needs inventory 23441: "consensus_framing", # redirect equal opportunity budget to quality 3569: "consensus_framing", # infrastructure investment counted as NATO 10285: "procedural_technical", # States General budget 2025 23058: "procedural_technical", # OCW budget 2024 3287: "procedural_technical", # inform parliament on humanitarian spending 10434: "consensus_framing", # integral future-proof media system 10089: "procedural_technical", # Asylum & Migration budget 2025 22706: "consensus_framing", # entrepreneur accord process 3877: "institutional_rule_of_law", # safety of converted asylum seekers 25062: "consensus_framing", # workable hazardous substances for SMEs 3687: "targeted_restriction", # EVRM interpretation protocol for asylum 25166: "procedural_technical", # detection dogs in prisons 4618: "procedural_technical", # Housing budget amendment 3468: "institutional_rule_of_law", # expand riot police weapons/defense 24632: "institutional_rule_of_law", # police access fatbike menu for enforcement 25451: "symbolic_declaratory", # calculate Palestine Authority pay-to-slay 2351: "targeted_restriction", # max 1yr prison for undesired declaration 4227: "consensus_framing", # Nijkerk bridge as strategic infrastructure 22853: "consensus_framing", # accelerate North Sea gas extraction 9884: "procedural_technical", # innovation contribution to emission reduction 1428: "consensus_framing", # liberalize trade with Canada/Mexico 3629: "symbolic_declaratory", # modernize UN Refugee Convention 1572: "local_constituency", # wolf attack impact mapping 25493: "procedural_technical", # defense materiel fund budget amendment 1359: "procedural_technical", # firework ban damage compensation estimate 2252: "procedural_technical", # municipal fund budget amendment 23605: "procedural_technical", # PAS melders legal verification process 3760: "consensus_framing", # Defense Readiness Act submission 1005: "consensus_framing", # EU import tariffs to support entrepreneurs 10110: "coalition_alignment", # budget amendment covering OCW package 23301: "consensus_framing", # international tendering military projects 24046: "symbolic_declaratory", # abstain from WHA accord (pandemic treaty) 651: "welfare_service_expansion", # agri nature management for Natuurnetwerk 1491: "targeted_restriction", # max wolf population Netherlands 25606: "targeted_restriction", # prevent wolf habituation to humans 313: "procedural_technical", # temporarily drop pre-filled tax return 24008: "consensus_framing", # EU approval frameworks for green agents 754: "targeted_restriction", # expel third-country nationals from Ukraine 25469: "targeted_restriction", # EU return hubs for asylum seekers 25091: "targeted_restriction", # stop asylum if travel to home country # === POST_LOW (75 motions, post-2024, centrist_support_strict <= 0.5) === 2170: "institutional_rule_of_law", # prison renovation budget amendment 22792: "procedural_technical", # investigate French espionage at Saab 10597: "institutional_rule_of_law", # remove third observer from preventive search 23013: "institutional_rule_of_law", # antisemitism combating work plan budget 3472: "institutional_rule_of_law", # minimum sentences for violence vs aid workers 2014: "system_dismantling", # limit asylum appeals to single instance 920: "procedural_technical", # transitional facility real estate box 3 2143: "welfare_service_expansion", # campaign working in healthcare 688: "system_dismantling", # reject Tromsø Convention accession 2290: "system_dismantling", # repeal municipal asylum task law 4497: "targeted_restriction", # stop funding terrorist organizations 3823: "symbolic_declaratory", # child attachment not against family return 23141: "institutional_rule_of_law", # deploy KMar for domestic security 4436: "institutional_rule_of_law", # standard aggravated sentence for aid worker violence 25616: "targeted_restriction", # scrap municipal status holder housing task 2662: "institutional_rule_of_law", # prevent NL germline modification tech export 23287: "institutional_rule_of_law", # community service ban for violence vs police 4660: "consensus_framing", # defense cooperation with Israel 4761: "targeted_restriction", # denaturalization and forced remigration 2264: "institutional_rule_of_law", # recover UvA demo damages from perpetrators 4394: "institutional_rule_of_law", # beanbag air-pressure weapon for police pilot 1691: "targeted_restriction", # no penal orders for criminal asylum seekers 10601: "targeted_restriction", # ban NGOs in human smuggling chain 4089: "targeted_restriction", # deny entry to Al-Hol camp persons 23206: "procedural_technical", # map NATO defense product leakage 22676: "institutional_rule_of_law", # offensive vs porn industry abuses 115: "system_dismantling", # oppose EU 90% emission reduction target 3951: "consensus_framing", # nuclear energy in CO2-low energy mix post-COP30 1375: "targeted_restriction", # enforce status holder housing priority ban 3090: "targeted_restriction", # ban Muslim Brotherhood in Netherlands 24650: "procedural_technical", # cash acceptance obligation for small payments 1772: "consensus_framing", # legislation for top-10 business climate 3678: "system_dismantling", # total asylum stop and family reunification stop 1692: "institutional_rule_of_law", # remove penal orders for serious crimes 24077: "symbolic_declaratory", # investigate Fatah role in Oct 7 attack 349: "institutional_rule_of_law", # increased penalty for organ removal/sexual exploitation 9769: "targeted_restriction", # return Syrians to rebuild their country 4656: "symbolic_declaratory", # no Ukraine NATO accession 23984: "system_dismantling", # don't raise eco-regulation requirements 2168: "institutional_rule_of_law", # prison budget for JeugdzorgPlus takeover 4443: "institutional_rule_of_law", # 200% sentence increase for violence vs public servants 4489: "procedural_technical", # fishing disturbance impact on scoter 10290: "targeted_restriction", # concrete migration project for JBZ Council 4071: "targeted_restriction", # investigate housing fraud by status holders 4088: "targeted_restriction", # agreements with third countries on asylum 1507: "system_dismantling", # empirical nature data as alternative to KDW 2870: "procedural_technical", # FGR transitional law amendment 1912: "system_dismantling", # repeal Spreidingswet 22658: "symbolic_declaratory", # no Dutch troops to Ukraine 10288: "targeted_restriction", # prepare Syrian return plan 4080: "institutional_rule_of_law", # research heavier forced re-education 1847: "targeted_restriction", # return hub for hopeless asylum seekers 23127: "system_dismantling", # restore 120/130 km/h speed limit 4367: "targeted_restriction", # no relaxation of EU accession for Ukraine 9790: "targeted_restriction", # no cooperation with IS returnees 4150: "procedural_technical", # fishing net selectivity/safety research 741: "targeted_restriction", # blue card minimum salary 1.3x average 1705: "consensus_framing", # reduce regulatory burden for industry 1831: "consensus_framing", # precautionary principle proportionality 10600: "targeted_restriction", # ban NGOs active in migrant smuggling 9767: "targeted_restriction", # no compulsory asylum reception in distribution decision 3830: "system_dismantling", # stop patronizing policy toward adults 4221: "system_dismantling", # overhead norm for public broadcasting 3354: "institutional_rule_of_law", # raise 3D-printed firearms max penalty 9977: "symbolic_declaratory", # oppose abolishing EU veto right 898: "consensus_framing", # simplify Omnibus and CSDDD 24848: "system_dismantling", # repeal Spreidingswet ASAP 756: "targeted_restriction", # temporary stop on family reunification 24358: "institutional_rule_of_law", # increase prison capacity via earlier lockup 4309: "institutional_rule_of_law", # targeted demographic policy for enforcement 10167: "local_constituency", # pilot projects for crayfish control 23633: "procedural_technical", # adjust parliament bell ringing 23030: "targeted_restriction", # no compulsory asylum places in distribution 1959: "system_dismantling", # no ban on plastic-containing wet wipes 23454: "procedural_technical", # legal analysis of pension transition risks } # ── sampling ───────────────────────────────────────────────────────────────── # Deterministic sample: 200 motions used for inline classification. # Motion IDs fixed to enable reproducible classification results. DETERMINISTIC_SAMPLE_IDS = { "pre_high": [4933, 8325, 9149, 11382, 13370, 13683, 14125, 14554, 15005, 15458, 16520, 16691, 16999, 17036, 17099, 17536, 17681, 17751, 18030, 18616, 20068, 21864, 21982, 26477, 26493], "pre_low": [6637, 7054, 7111, 12411, 14837, 15082, 15626, 15772, 16430, 17176, 18025, 18062, 18691, 19464, 19620, 20115, 20215, 20323, 21801, 22280, 22595, 25784, 25982, 26855, 27731], "post_high": [313, 473, 651, 754, 974, 1005, 1191, 1359, 1428, 1491, 1495, 1572, 1614, 1890, 2070, 2252, 2351, 2496, 2878, 2980, 3053, 3287, 3298, 3448, 3468, 3569, 3629, 3687, 3760, 3784, 3877, 3961, 4227, 4481, 4618, 9789, 9884, 10089, 10110, 10178, 10205, 10278, 10285, 10413, 10420, 10434, 22706, 22853, 23058, 23065, 23301, 23441, 23605, 23885, 23910, 24008, 24009, 24046, 24632, 24651, 24906, 25061, 25062, 25079, 25091, 25092, 25166, 25451, 25469, 25493, 25545, 25566, 25573, 25582, 25606], "post_low": [115, 349, 688, 741, 756, 898, 920, 1375, 1507, 1691, 1692, 1705, 1772, 1831, 1847, 1912, 1959, 2014, 2143, 2168, 2170, 2264, 2290, 2662, 2870, 3090, 3354, 3472, 3678, 3823, 3830, 3951, 4071, 4080, 4088, 4089, 4150, 4221, 4309, 4367, 4394, 4436, 4443, 4489, 4497, 4656, 4660, 4761, 9767, 9769, 9790, 9977, 10167, 10288, 10290, 10597, 10600, 10601, 22658, 22676, 22792, 23013, 23030, 23127, 23141, 23206, 23287, 23454, 23633, 23984, 24077, 24358, 24650, 24848, 25616], } def sample_motions( db_path: str, n_pre_high: int = 25, n_pre_low: int = 25, n_post_high: int = 75, n_post_low: int = 75, seed: int = 42, ) -> list[dict[str, Any]]: """Deterministic sample of right_wing_motions JOIN motions using known IDs.""" all_ids = [] stratum_map = {} for stratum, ids in DETERMINISTIC_SAMPLE_IDS.items(): for mid in ids: all_ids.append(mid) stratum_map[mid] = stratum con = duckdb.connect(db_path) try: placeholders = ",".join("?" for _ in all_ids) rows = con.execute( f""" SELECT r.motion_id, m.title, m.body_text, r.year, r.centrist_support_strict FROM right_wing_motions r JOIN motions m ON r.motion_id = m.id WHERE r.motion_id IN ({placeholders}) ORDER BY r.motion_id """, all_ids, ).fetchall() return [ { "motion_id": r[0], "title": r[1] or "", "body_text": r[2] or "", "year": r[3], "centrist_support_strict": r[4], "stratum": stratum_map.get(r[0], "unknown"), } for r in rows ] finally: con.close() # ── analysis ───────────────────────────────────────────────────────────────── def compute_distribution( sample: list[dict[str, Any]], classifications: dict[int, str], ) -> dict[str, Any]: """Compute mechanism distribution by period and support level.""" # Build distribution table groups: dict[str, Counter[str]] = { "pre_high": Counter(), "pre_low": Counter(), "post_high": Counter(), "post_low": Counter(), } classified = 0 unclassified = 0 for motion in sample: mid = motion["motion_id"] stratum = motion["stratum"] mechanism = classifications.get(mid) if mechanism and mechanism in MECHANISMS: groups[stratum][mechanism] += 1 classified += 1 else: unclassified += 1 groups[stratum]["unclassified"] = groups[stratum].get("unclassified", 0) + 1 # type: ignore[index] # Build contingency table for chi-squared: period × mechanism # Consolidate: pre = pre_high + pre_low, post = post_high + post_low pre_counts = groups["pre_high"] + groups["pre_low"] post_counts = groups["post_high"] + groups["post_low"] # Contingency table: rows=mechanisms, cols=[pre, post] contingency_pre_post = [] row_labels = [] for mech in MECHANISMS: row = [pre_counts.get(mech, 0), post_counts.get(mech, 0)] if sum(row) > 0: contingency_pre_post.append(row) row_labels.append(mech) chi2_result = None if len(contingency_pre_post) >= 2: arr = np.array(contingency_pre_post) # Only include rows/cols with sufficient data if arr.sum() > 0 and arr.shape[0] >= 2 and arr.shape[1] >= 2: try: chi2, pval, dof, expected = chi2_contingency(arr) chi2_result = { "chi2": float(chi2), "p_value": float(pval), "dof": int(dof), "significant": bool(pval < 0.05), } except ValueError: chi2_result = {"error": "Invalid contingency table"} # High vs low support within post-2024 only post_high_counts = groups["post_high"] post_low_counts = groups["post_low"] contingency_hl = [] hl_labels = [] for mech in MECHANISMS: row = [post_high_counts.get(mech, 0), post_low_counts.get(mech, 0)] if sum(row) > 0: contingency_hl.append(row) hl_labels.append(mech) chi2_hl_result = None if len(contingency_hl) >= 2: arr_hl = np.array(contingency_hl) if arr_hl.sum() > 0 and arr_hl.shape[0] >= 2 and arr_hl.shape[1] >= 2: try: chi2, pval, dof, expected = chi2_contingency(arr_hl) chi2_hl_result = { "chi2": float(chi2), "p_value": float(pval), "dof": int(dof), "significant": bool(pval < 0.05), } except ValueError: chi2_hl_result = {"error": "Invalid contingency table"} # Specific test: consensus_framing in post_high vs post_low cf_post_high = post_high_counts.get("consensus_framing", 0) cf_post_low = post_low_counts.get("consensus_framing", 0) total_post_high = sum(post_high_counts.values()) total_post_low = sum(post_low_counts.values()) cf_ratio_high = cf_post_high / total_post_high if total_post_high else 0 cf_ratio_low = cf_post_low / total_post_low if total_post_low else 0 # Fisher-style 2x2 for consensus_framing in post: high vs low non_cf_post_high = total_post_high - cf_post_high non_cf_post_low = total_post_low - cf_post_low cf_2x2 = np.array([[cf_post_high, non_cf_post_high], [cf_post_low, non_cf_post_low]]) cf_chi2_result = None if cf_2x2.min() >= 0: try: chi2, pval, dof, _ = chi2_contingency(cf_2x2) cf_chi2_result = { "chi2": float(chi2), "p_value": float(pval), "dof": int(dof), "significant": bool(pval < 0.05), "cf_ratio_high": round(cf_ratio_high, 4), "cf_ratio_low": round(cf_ratio_low, 4), "cf_count_high": cf_post_high, "cf_count_low": cf_post_low, "total_high": total_post_high, "total_low": total_post_low, } except ValueError: cf_chi2_result = {"error": "Invalid 2x2 table"} # Pre vs post consensus framing cf_pre = pre_counts.get("consensus_framing", 0) cf_post = post_counts.get("consensus_framing", 0) total_pre = sum(pre_counts.values()) total_post = sum(post_counts.values()) return { "sample_size": len(sample), "classified": classified, "unclassified": unclassified, "distribution": {s: dict(g.most_common()) for s, g in groups.items()}, "mechanism_totals_pre": dict(pre_counts.most_common()), "mechanism_totals_post": dict(post_counts.most_common()), "chi2_pre_vs_post": chi2_result, "chi2_post_high_vs_low": chi2_hl_result, "consensus_framing_test": cf_chi2_result, "cf_pre_post": { "cf_pre": cf_pre, "cf_post": cf_post, "total_pre": total_pre, "total_post": total_post, "ratio_pre": round(cf_pre / total_pre, 4) if total_pre else 0, "ratio_post": round(cf_post / total_post, 4) if total_post else 0, }, } # ── report generation ──────────────────────────────────────────────────────── def generate_report(results: dict[str, Any], output_path: str) -> None: """Generate mechanism classification markdown report.""" dist = results["distribution"] cf_test = results["consensus_framing_test"] cf_pp = results["cf_pre_post"] lines = [ "# Mechanism Classification Report", "", f"**Sample:** {results['sample_size']} motions (stratified: 50 pre-2024, 150 post-2024)", f"**Classified:** {results['classified']} motions | **Unclassified:** {results['unclassified']}", "", "## 1. Mechanism Distribution by Group", "", "### Pre-2024, High Centrist Support (CS > 0.5)", "", "| Mechanism | Count | Pct |", "|-----------|-------|-----|", ] pre_high = dist.get("pre_high", {}) pre_high_total = sum(pre_high.values()) for mech in MECHANISMS: cnt = pre_high.get(mech, 0) pct = f"{cnt / pre_high_total * 100:.1f}%" if pre_high_total else "0%" label = MECHANISM_LABELS_NL.get(mech, mech) lines.append(f"| {label} | {cnt} | {pct} |") lines.append(f"| **Total** | **{pre_high_total}** | **100%** |") lines.extend([ "", "### Pre-2024, Low Centrist Support (CS <= 0.5)", "", "| Mechanism | Count | Pct |", "|-----------|-------|-----|", ]) pre_low = dist.get("pre_low", {}) pre_low_total = sum(pre_low.values()) for mech in MECHANISMS: cnt = pre_low.get(mech, 0) pct = f"{cnt / pre_low_total * 100:.1f}%" if pre_low_total else "0%" label = MECHANISM_LABELS_NL.get(mech, mech) lines.append(f"| {label} | {cnt} | {pct} |") lines.append(f"| **Total** | **{pre_low_total}** | **100%** |") lines.extend([ "", "### Post-2024, High Centrist Support (CS > 0.5)", "", "| Mechanism | Count | Pct |", "|-----------|-------|-----|", ]) post_high = dist.get("post_high", {}) post_high_total = sum(post_high.values()) for mech in MECHANISMS: cnt = post_high.get(mech, 0) pct = f"{cnt / post_high_total * 100:.1f}%" if post_high_total else "0%" label = MECHANISM_LABELS_NL.get(mech, mech) lines.append(f"| {label} | {cnt} | {pct} |") lines.append(f"| **Total** | **{post_high_total}** | **100%** |") lines.extend([ "", "### Post-2024, Low Centrist Support (CS <= 0.5)", "", "| Mechanism | Count | Pct |", "|-----------|-------|-----|", ]) post_low = dist.get("post_low", {}) post_low_total = sum(post_low.values()) for mech in MECHANISMS: cnt = post_low.get(mech, 0) pct = f"{cnt / post_low_total * 100:.1f}%" if post_low_total else "0%" label = MECHANISM_LABELS_NL.get(mech, mech) lines.append(f"| {label} | {cnt} | {pct} |") lines.append(f"| **Total** | **{post_low_total}** | **100%** |") # Summary: Pre vs Post lines.extend([ "", "## 2. Consolidated Pre vs Post-2024 Distribution", "", "| Mechanism | Pre-2024 | Pct Pre | Post-2024 | Pct Post |", "|-----------|----------|---------|-----------|----------|", ]) pre_cons = results["mechanism_totals_pre"] post_cons = results["mechanism_totals_post"] pre_total = sum(pre_cons.values()) post_total = sum(post_cons.values()) for mech in MECHANISMS: pre_cnt = pre_cons.get(mech, 0) post_cnt = post_cons.get(mech, 0) pre_pct = f"{pre_cnt / pre_total * 100:.1f}%" if pre_total else "0%" post_pct = f"{post_cnt / post_total * 100:.1f}%" if post_total else "0%" label = MECHANISM_LABELS_NL.get(mech, mech) lines.append(f"| {label} | {pre_cnt} | {pre_pct} | {post_cnt} | {post_pct} |") lines.append(f"| **Total** | **{pre_total}** | **100%** | **{post_total}** | **100%** |") # Consensus framing focus lines.extend([ "", "## 3. Consensus Framing Hypothesis Test", "", f"**H0:** Consensus framing is equally common in high-support and low-support post-2024 motions.", f"**H1:** Consensus framing is significantly more common in high-support post-2024 motions.", "", ]) if cf_test and "error" not in cf_test: lines.append(f"- Consensus framing in post-2024 HIGH: {cf_test['cf_count_high']}/{cf_test['total_high']} ({cf_test['cf_ratio_high']:.1%})") lines.append(f"- Consensus framing in post-2024 LOW: {cf_test['cf_count_low']}/{cf_test['total_low']} ({cf_test['cf_ratio_low']:.1%})") lines.append(f"- χ²(1) = {cf_test['chi2']:.3f}, p = {cf_test['p_value']:.4f}") if cf_test["significant"]: lines.append(f"- **Result: Significant difference (p < 0.05). Consensus framing IS more common in high-support post-2024 motions.**") else: lines.append(f"- **Result: Not significant (p >= 0.05). Cannot reject the null.**") else: lines.append("- Consensus framing test could not be performed (insufficient data).") lines.extend([ "", f"- Consensus framing pre-2024: {cf_pp['cf_pre']}/{cf_pp['total_pre']} ({cf_pp['ratio_pre']:.1%})", f"- Consensus framing post-2024: {cf_pp['cf_post']}/{cf_pp['total_post']} ({cf_pp['ratio_post']:.1%})", ]) # Chi-squared tests chi2_all = results["chi2_pre_vs_post"] if chi2_all and "error" not in chi2_all: lines.extend([ "", "## 4. Chi-Squared Test: Period × Mechanism", "", f"- χ²({chi2_all['dof']}) = {chi2_all['chi2']:.3f}, p = {chi2_all['p_value']:.4f}", f"- {'Significant' if chi2_all['significant'] else 'Not significant'} difference in mechanism distribution between pre and post-2024.", ]) chi2_hl = results["chi2_post_high_vs_low"] if chi2_hl and "error" not in chi2_hl: lines.extend([ "", "## 5. Chi-Squared Test: Support Level × Mechanism (Post-2024)", "", f"- χ²({chi2_hl['dof']}) = {chi2_hl['chi2']:.3f}, p = {chi2_hl['p_value']:.4f}", f"- {'Significant' if chi2_hl['significant'] else 'Not significant'} difference in mechanism distribution between high and low support post-2024 motions.", ]) lines.extend([ "", "## 6. Key Findings", "", ]) # Compute and report key findings # Which mechanisms dominate in high-support post-2024? post_high_sorted = sorted(post_high.items(), key=lambda x: x[1], reverse=True) post_low_sorted = sorted(post_low.items(), key=lambda x: x[1], reverse=True) lines.append("### Top 3 mechanisms in post-2024 HIGH-support motions:") for mech, cnt in post_high_sorted[:3]: label = MECHANISM_LABELS_NL.get(mech, mech) pct = cnt / post_high_total * 100 lines.append(f"- {label}: {cnt} ({pct:.1f}%)") lines.append("") lines.append("### Top 3 mechanisms in post-2024 LOW-support motions:") for mech, cnt in post_low_sorted[:3]: label = MECHANISM_LABELS_NL.get(mech, mech) pct = cnt / post_low_total * 100 lines.append(f"- {label}: {cnt} ({pct:.1f}%)") # Shift analysis lines.extend([ "", "### Mechanism shifts from pre to post-2024", "", "| Mechanism | Pre Pct | Post Pct | Δ |", "|-----------|---------|----------|---|", ]) for mech in MECHANISMS: pre_cnt = pre_cons.get(mech, 0) post_cnt = post_cons.get(mech, 0) pre_pct = pre_cnt / pre_total * 100 if pre_total else 0 post_pct = post_cnt / post_total * 100 if post_total else 0 delta = post_pct - pre_pct label = MECHANISM_LABELS_NL.get(mech, mech) lines.append(f"| {label} | {pre_pct:.1f}% | {post_pct:.1f}% | {delta:+.1f}% |") lines.extend([ "", "## 7. Conclusion", "", ]) # Interpretation cf_consensus = "" if cf_test and "error" not in cf_test: if cf_test["significant"] and cf_test["cf_ratio_high"] > cf_test["cf_ratio_low"]: cf_consensus = ( f"The consensus framing hypothesis **is supported**: consensus framing motions " f"are {cf_test['cf_ratio_high']:.1%} of high-support post-2024 motions vs " f"{cf_test['cf_ratio_low']:.1%} of low-support post-2024 motions " f"(χ² = {cf_test['chi2']:.3f}, p = {cf_test['p_value']:.4f})." ) else: cf_consensus = ( f"The consensus framing hypothesis **is not supported**: no significant difference " f"between high ({cf_test['cf_ratio_high']:.1%}) and low ({cf_test['cf_ratio_low']:.1%}) " f"support post-2024 motions (p = {cf_test['p_value']:.4f})." ) lines.append(cf_consensus) lines.append("") lines.append("### Limitations") lines.append("- Sample: 200 motions (50 pre, 150 post) — may not capture rare mechanisms") lines.append("- Single-classifier: all motions classified by one subagent (inline), no inter-rater validation") lines.append("- Binary support threshold: CS > 0.5 vs <= 0.5 may oversimplify the support spectrum") lines.append("- Mechanism assignment: single primary mechanism per motion; some motions span multiple categories") # Write output out_path = Path(output_path) out_path.parent.mkdir(parents=True, exist_ok=True) out_path.write_text("\n".join(lines) + "\n", encoding="utf-8") print(f"Report written to {out_path}") # ── main ───────────────────────────────────────────────────────────────────── def main() -> int: parser = argparse.ArgumentParser(description="Systematic mechanism classification") parser.add_argument("--db", default="data/motions.db", help="Path to DuckDB database") parser.add_argument("--n-pre-high", type=int, default=25) parser.add_argument("--n-pre-low", type=int, default=25) parser.add_argument("--n-post-high", type=int, default=75) parser.add_argument("--n-post-low", type=int, default=75) parser.add_argument("--seed", type=int, default=42) parser.add_argument("--output", default="reports/overton_window/mechanism_classification.md") parser.add_argument("--save-classifications", help="Save classifications JSON to path") args = parser.parse_args() # Sample motions sample = sample_motions( db_path=args.db, n_pre_high=args.n_pre_high, n_pre_low=args.n_pre_low, n_post_high=args.n_post_high, n_post_low=args.n_post_low, seed=args.seed, ) print(f"Sampled {len(sample)} motions") # Optional: save classifications mapping if args.save_classifications: class_path = Path(args.save_classifications) class_path.parent.mkdir(parents=True, exist_ok=True) class_path.write_text(json.dumps(CLASSIFICATIONS, indent=2, ensure_ascii=False), encoding="utf-8") print(f"Classifications saved to {class_path}") # Compute distribution results = compute_distribution(sample, CLASSIFICATIONS) print(f"Classified: {results['classified']}, Unclassified: {results['unclassified']}") # Generate report generate_report(results, args.output) # Print summary to stdout cf_test = results["consensus_framing_test"] if cf_test and "error" not in cf_test: print(f"\nConsensus Framing Test:") print(f" Post-2024 HIGH: {cf_test['cf_count_high']}/{cf_test['total_high']} = {cf_test['cf_ratio_high']:.1%}") print(f" Post-2024 LOW: {cf_test['cf_count_low']}/{cf_test['total_low']} = {cf_test['cf_ratio_low']:.1%}") print(f" χ² = {cf_test['chi2']:.3f}, p = {cf_test['p_value']:.4f} ({'SIGNIFICANT' if cf_test['significant'] else 'NOT significant'})") return 0 if __name__ == "__main__": raise SystemExit(main())