import numpy as np import types import sys import pytest # --------------------------------------------------------------------------- # Helpers shared by orientation tests # --------------------------------------------------------------------------- def _make_fake_traj(aligned): fake = types.SimpleNamespace() fake._load_window_ids = lambda db: list(aligned.keys()) fake._load_mp_vectors_for_window = lambda db, w: aligned.get(w, {}) fake._procrustes_align_windows = lambda x: aligned return fake def test_compute_2d_axes_pca_synthetic(monkeypatch): """Synthetic test for compute_2d_axes using patched alignment helper.""" # Create a fake trajectory module with required helpers fake_traj = types.SimpleNamespace() # _load_window_ids should return ordered windows fake_traj._load_window_ids = lambda db: ["w1", "w2"] # Provide aligned vectors directly aligned = { "w1": {"Alice": np.array([1.0, 0.0, 0.0]), "Bob": np.array([0.0, 1.0, 0.0])}, "w2": {"Alice": np.array([0.8, 0.2, 0.0]), "Bob": np.array([0.1, 0.9, 0.0])}, } # _load_mp_vectors_for_window returns the pre-aligned vectors (needed for padding step) fake_traj._load_mp_vectors_for_window = lambda db, w: aligned.get(w, {}) fake_traj._procrustes_align_windows = lambda x: aligned # Insert fake module into sys.modules for import by analysis.political_axis monkeypatch.setitem(sys.modules, "analysis.trajectory", fake_traj) # Now import the function under test from analysis.political_axis import compute_2d_axes positions_by_window, axis_def = compute_2d_axes( db_path="dummy", window_ids=["w1", "w2"], method="pca" ) assert "w1" in positions_by_window and "w2" in positions_by_window for wid in ("w1", "w2"): for name, coord in positions_by_window[wid].items(): assert len(coord) == 2 assert np.isfinite(coord[0]) and np.isfinite(coord[1]) assert axis_def.get("method") == "pca" def test_pca_axis_orientation(monkeypatch): """PCA axes must be oriented so right parties score higher on X and progressive parties score higher on Y than their respective opposites. We construct a minimal vote-matrix world where: - Right MPs (PVV, VVD members) cluster in one direction on dim-0. - Left MPs (SP, GroenLinks-PvdA members) cluster in the opposite direction. - Progressive MPs cluster on dim-1; conservative MPs on the opposite side. The orientation logic in compute_2d_axes should flip axis signs so that right_x > left_x and prog_y > cons_y regardless of the raw SVD sign. """ # Build vectors so that right parties are at +1 on dim-0 and # progressive parties are at +1 on dim-1. # We deliberately negate them to test that auto-orient flips them back. # Right/left use magnitude 3, prog/cons use magnitude 1 so that dim-0 # dominates PCA variance — ensuring PC1 = left-right axis, PC2 = prog-cons. right_vec = np.array([-3.0, 0.0, 0.0]) # intentionally negative on dim-0 left_vec = np.array([3.0, 0.0, 0.0]) # intentionally positive on dim-0 prog_vec = np.array([0.0, -1.0, 0.0]) # intentionally negative on dim-1 cons_vec = np.array([0.0, 1.0, 0.0]) # intentionally positive on dim-1 aligned = { "w1": { # Right-leaning MPs "Wilders, G.": right_vec, "Rutte, M.": right_vec + np.array([0.0, 0.0, 0.05]), # Left-leaning MPs "Marijnissen, L.": left_vec, "Klever, A.": left_vec + np.array([0.0, 0.0, 0.05]), # Progressive MPs "Bromet, L.": prog_vec, "Nijboer, H.": prog_vec + np.array([0.0, 0.0, -0.05]), # Conservative MPs "Segers, G.": cons_vec, "Omtzigt, P.": cons_vec + np.array([0.0, 0.0, -0.05]), } } # mp_metadata rows used by the orientation code (party affiliation) mp_metadata = [ ("Wilders, G.", "PVV"), ("Rutte, M.", "VVD"), ("Marijnissen, L.", "SP"), ("Klever, A.", "GroenLinks-PvdA"), ("Bromet, L.", "GroenLinks-PvdA"), ("Nijboer, H.", "SP"), ("Segers, G.", "CDA"), ("Omtzigt, P.", "Nieuw Sociaal Contract"), ] fake_traj = _make_fake_traj(aligned) monkeypatch.setitem(sys.modules, "analysis.trajectory", fake_traj) # Patch duckdb so the orientation helper can fetch mp_metadata import types as _types fake_conn = _types.SimpleNamespace( execute=lambda q: _types.SimpleNamespace(fetchall=lambda: mp_metadata), close=lambda: None, ) import duckdb as _duckdb monkeypatch.setattr(_duckdb, "connect", lambda db_path, **kw: fake_conn) # Need to reload the module so monkeypatched sys.modules takes effect import importlib, analysis.political_axis as _ax importlib.reload(_ax) from analysis.political_axis import compute_2d_axes positions_by_window, axis_def = compute_2d_axes( db_path="dummy", window_ids=["w1"], method="pca" ) pos = positions_by_window["w1"] # X-axis: right parties should score higher than left parties right_x = np.mean([pos["Wilders, G."][0], pos["Rutte, M."][0]]) left_x = np.mean([pos["Marijnissen, L."][0], pos["Klever, A."][0]]) assert right_x > left_x, ( f"Expected right parties (x={right_x:.3f}) > left parties (x={left_x:.3f}) on X-axis" ) # Y-axis: progressive parties should score higher than conservative parties prog_y = np.mean([pos["Bromet, L."][1], pos["Nijboer, H."][1]]) cons_y = np.mean([pos["Segers, G."][1], pos["Omtzigt, P."][1]]) assert prog_y > cons_y, ( f"Expected progressive parties (y={prog_y:.3f}) > conservative parties (y={cons_y:.3f}) on Y-axis" )