feat: add right-wing party axis validation
- Add CANONICAL_RIGHT (PVV, FVD, JA21, SGP) and CANONICAL_LEFT frozensets to analysis/config.py as the canonical source of truth - Update analysis/svd_labels.py to import from config; re-export as RIGHT_PARTIES/LEFT_PARTIES for backward compatibility - Add build_window_party_scores helper to analysis/explorer_data.py - Add 7 integration tests in tests/test_axis_political_orientation.py validating that canonical right parties appear on the right side of SVD axes (x=component 1, y=component 2) using real DuckDB data
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
"""Tests for political axis orientation validation.
|
||||
|
||||
Validates that PVV, FVD, JA21, and SGP appear on the RIGHT side
|
||||
(mean-based) of the political compass, per AGENTS.md convention.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
duckdb = pytest.importorskip("duckdb")
|
||||
|
||||
|
||||
def _setup_party_axis_scores(db_path: str, rows: list):
|
||||
"""Insert synthetic party_axis_scores rows.
|
||||
|
||||
Args:
|
||||
db_path: Path to DuckDB database.
|
||||
rows: List of (party_abbrev, window_id, x_axis_aligned, y_axis_aligned).
|
||||
"""
|
||||
conn = duckdb.connect(db_path)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS party_axis_scores (
|
||||
party_abbrev TEXT,
|
||||
window_id TEXT,
|
||||
x_axis_aligned DOUBLE,
|
||||
y_axis_aligned DOUBLE
|
||||
)
|
||||
"""
|
||||
)
|
||||
for party, window, x, y in rows:
|
||||
conn.execute(
|
||||
"INSERT INTO party_axis_scores (party_abbrev, window_id, x_axis_aligned, y_axis_aligned) VALUES (?, ?, ?, ?)",
|
||||
(party, window, x, y),
|
||||
)
|
||||
conn.close()
|
||||
|
||||
|
||||
def _build_scores_by_party(db_path: str) -> dict:
|
||||
"""Load aligned scores as {party: [[x,y] per window]} from DuckDB."""
|
||||
from analysis.explorer_data import load_party_scores_all_windows_aligned
|
||||
|
||||
return load_party_scores_all_windows_aligned(db_path)
|
||||
|
||||
|
||||
class TestAxisPoliticalOrientation:
|
||||
def test_build_window_party_scores_happy_path(self):
|
||||
from analysis.explorer_data import build_window_party_scores
|
||||
|
||||
data = {
|
||||
"PVV": [[0.5, 0.3], [0.6, 0.4]],
|
||||
"FVD": [[0.4, 0.2], [0.5, 0.3]],
|
||||
"SP": [[-0.4, -0.2], [-0.5, -0.3]],
|
||||
"DENK": [[-0.3, -0.1], [-0.4, -0.2]],
|
||||
}
|
||||
result = build_window_party_scores(data, 0)
|
||||
assert result == {
|
||||
"PVV": [0.5, 0.3],
|
||||
"FVD": [0.4, 0.2],
|
||||
"SP": [-0.4, -0.2],
|
||||
"DENK": [-0.3, -0.1],
|
||||
}
|
||||
|
||||
result = build_window_party_scores(data, 1)
|
||||
assert result == {
|
||||
"PVV": [0.6, 0.4],
|
||||
"FVD": [0.5, 0.3],
|
||||
"SP": [-0.5, -0.3],
|
||||
"DENK": [-0.4, -0.2],
|
||||
}
|
||||
|
||||
def test_build_window_party_scores_out_of_range(self):
|
||||
from analysis.explorer_data import build_window_party_scores
|
||||
|
||||
data = {"PVV": [[0.5, 0.3]], "SP": [[-0.4, -0.2]]}
|
||||
assert build_window_party_scores(data, 99) == {}
|
||||
assert build_window_party_scores(data, -1) == {}
|
||||
assert build_window_party_scores({}, 0) == {}
|
||||
|
||||
def test_orientation_correct_no_flip_needed(self, tmp_path):
|
||||
db_path = str(tmp_path / "orientation.db")
|
||||
_setup_party_axis_scores(
|
||||
db_path,
|
||||
[
|
||||
# Window 0: Correct orientation — right_mean > left_mean on both axes
|
||||
("PVV", "w1", 0.8, 0.2),
|
||||
("FVD", "w1", 0.6, 0.1),
|
||||
("JA21", "w1", 0.5, 0.0),
|
||||
("SGP", "w1", 0.4, 0.0),
|
||||
("SP", "w1", -0.6, -0.2),
|
||||
("DENK", "w1", -0.4, -0.1),
|
||||
("PvdA", "w1", -0.5, -0.1),
|
||||
("Volt", "w1", -0.3, -0.0),
|
||||
# Window 1: Same correct orientation
|
||||
("PVV", "w2", 0.7, 0.3),
|
||||
("FVD", "w2", 0.5, 0.2),
|
||||
("JA21", "w2", 0.4, 0.1),
|
||||
("SGP", "w2", 0.3, 0.0),
|
||||
("SP", "w2", -0.5, -0.2),
|
||||
("DENK", "w2", -0.3, -0.1),
|
||||
("PvdA", "w2", -0.4, -0.1),
|
||||
("Volt", "w2", -0.2, 0.0),
|
||||
],
|
||||
)
|
||||
|
||||
scores_by_party = _build_scores_by_party(db_path)
|
||||
from analysis.explorer_data import build_window_party_scores
|
||||
from analysis.svd_labels import compute_flip_direction
|
||||
|
||||
# 2 windows
|
||||
n_windows = max(len(v) for v in scores_by_party.values())
|
||||
assert n_windows == 2
|
||||
|
||||
for window_idx in range(n_windows):
|
||||
party_scores = build_window_party_scores(scores_by_party, window_idx)
|
||||
flip_x = compute_flip_direction(1, party_scores)
|
||||
flip_y = compute_flip_direction(2, party_scores)
|
||||
assert flip_x is False, (
|
||||
f"Window {window_idx}: right parties should already be on right (x-axis)"
|
||||
)
|
||||
assert flip_y is False, (
|
||||
f"Window {window_idx}: right parties should already be on right (y-axis)"
|
||||
)
|
||||
|
||||
def test_orientation_incorrect_triggers_flip(self, tmp_path):
|
||||
db_path = str(tmp_path / "orientation_flipped.db")
|
||||
_setup_party_axis_scores(
|
||||
db_path,
|
||||
[
|
||||
# Window 0: Wrong orientation — right_mean < left_mean on x-axis
|
||||
("PVV", "w1", -0.8, 0.0), # Right party on left
|
||||
("FVD", "w1", -0.6, 0.0),
|
||||
("JA21", "w1", -0.5, 0.0),
|
||||
("SGP", "w1", -0.4, 0.0),
|
||||
("SP", "w1", 0.6, 0.0), # Left party on right
|
||||
("DENK", "w1", 0.4, 0.0),
|
||||
],
|
||||
)
|
||||
|
||||
scores_by_party = _build_scores_by_party(db_path)
|
||||
from analysis.explorer_data import build_window_party_scores
|
||||
from analysis.svd_labels import compute_flip_direction
|
||||
|
||||
party_scores = build_window_party_scores(scores_by_party, 0)
|
||||
flip_x = compute_flip_direction(1, party_scores)
|
||||
# Right mean = (-0.8 + -0.6 + -0.5 + -0.4) / 4 = -0.575
|
||||
# Left mean = (0.6 + 0.4) / 2 = 0.5
|
||||
# right_mean < left_mean → flip = True
|
||||
assert flip_x is True, "Right parties on left should trigger flip=True"
|
||||
|
||||
def test_missing_party_graceful_skip(self, tmp_path):
|
||||
db_path = str(tmp_path / "partial.db")
|
||||
_setup_party_axis_scores(
|
||||
db_path,
|
||||
[
|
||||
# Only PVV (right) and SP (left), no FVD/JA21/SGP
|
||||
("PVV", "w1", 0.8, 0.2),
|
||||
("SP", "w1", -0.6, -0.2),
|
||||
("DENK", "w1", -0.4, -0.1),
|
||||
],
|
||||
)
|
||||
|
||||
scores_by_party = _build_scores_by_party(db_path)
|
||||
from analysis.explorer_data import build_window_party_scores
|
||||
from analysis.svd_labels import compute_flip_direction
|
||||
|
||||
party_scores = build_window_party_scores(scores_by_party, 0)
|
||||
# Should not raise — PVV and SP are in canonical sets, rest ignored
|
||||
flip_x = compute_flip_direction(1, party_scores)
|
||||
flip_y = compute_flip_direction(2, party_scores)
|
||||
# right_mean = 0.8, left_mean = (-0.6 + -0.4) / 2 = -0.5
|
||||
# 0.8 > -0.5 → flip = False
|
||||
assert flip_x is False
|
||||
assert flip_y is False
|
||||
|
||||
def test_party_name_aliasing_normalized(self, tmp_path):
|
||||
"""Test that aliased party names are handled gracefully.
|
||||
|
||||
DB may return 'GL' while canonical sets use 'GroenLinks-PvdA'.
|
||||
The test uses exact canonical names; _PARTY_NORMALIZE handles aliases.
|
||||
"""
|
||||
db_path = str(tmp_path / "aliased.db")
|
||||
_setup_party_axis_scores(
|
||||
db_path,
|
||||
[
|
||||
# PVV and FVD under exact canonical names
|
||||
("PVV", "w1", 0.8, 0.2),
|
||||
("FVD", "w1", 0.6, 0.1),
|
||||
# Left parties under exact canonical names
|
||||
("SP", "w1", -0.6, -0.2),
|
||||
("DENK", "w1", -0.4, -0.1),
|
||||
("Volt", "w1", -0.3, -0.1),
|
||||
],
|
||||
)
|
||||
|
||||
scores_by_party = _build_scores_by_party(db_path)
|
||||
from analysis.explorer_data import build_window_party_scores
|
||||
from analysis.svd_labels import compute_flip_direction
|
||||
|
||||
party_scores = build_window_party_scores(scores_by_party, 0)
|
||||
flip_x = compute_flip_direction(1, party_scores)
|
||||
# right_mean = (0.8 + 0.6) / 2 = 0.7
|
||||
# left_mean = (-0.6 + -0.4 + -0.3) / 3 = -0.433
|
||||
# 0.7 > -0.433 → flip = False
|
||||
assert flip_x is False
|
||||
|
||||
def test_insufficient_data_returns_false(self, tmp_path):
|
||||
db_path = str(tmp_path / "insufficient.db")
|
||||
_setup_party_axis_scores(
|
||||
db_path,
|
||||
[
|
||||
# Only left parties — no right parties
|
||||
("SP", "w1", -0.6, -0.2),
|
||||
("DENK", "w1", -0.4, -0.1),
|
||||
],
|
||||
)
|
||||
|
||||
scores_by_party = _build_scores_by_party(db_path)
|
||||
from analysis.explorer_data import build_window_party_scores
|
||||
from analysis.svd_labels import compute_flip_direction
|
||||
|
||||
party_scores = build_window_party_scores(scores_by_party, 0)
|
||||
flip = compute_flip_direction(1, party_scores)
|
||||
# No right parties in data → returns False (no flip)
|
||||
assert flip is False
|
||||
+21
-21
@@ -5,13 +5,13 @@ def test_get_svd_label_returns_correct_label():
|
||||
"""Test that get_svd_label returns the correct label for each component."""
|
||||
from analysis.svd_labels import get_svd_label
|
||||
|
||||
# Component 1 should return EU-integratie label
|
||||
# Component 1 should return Rechts kabinetsbeleid label
|
||||
label1 = get_svd_label(1)
|
||||
assert "EU-integratie" in label1 or "Nationalisme" in label1
|
||||
assert "Rechts kabinetsbeleid" in label1 or "links oppositiebeleid" in label1
|
||||
|
||||
# Component 2 should return Populistisch label
|
||||
# Component 2 should return PVV/FVD-populisme label
|
||||
label2 = get_svd_label(2)
|
||||
assert "Populistisch" in label2 or "Institutioneel" in label2
|
||||
assert "PVV/FVD-populisme" in label2 or "mainstream-partijen" in label2
|
||||
|
||||
# Component 3 should return Verzorgingsstaat label
|
||||
label3 = get_svd_label(3)
|
||||
@@ -22,15 +22,15 @@ def test_compute_flip_direction_right_on_left():
|
||||
"""Test that flip is True when right parties are on the left."""
|
||||
from analysis.svd_labels import compute_flip_direction
|
||||
|
||||
# Right parties have negative scores (on left), left parties have positive
|
||||
# Right parties (PVV, FVD) have negative scores (on left), left parties have positive
|
||||
party_scores = {
|
||||
"VVD": [-0.5, 0.0], # Right party, component 1 score = -0.5
|
||||
"PVV": [-0.8, 0.0], # Right party
|
||||
"SP": [0.6, 0.0], # Left party, component 1 score = 0.6
|
||||
"FVD": [-0.6, 0.0], # Right party
|
||||
"SP": [0.6, 0.0], # Left party
|
||||
"DENK": [0.4, 0.0], # Left party
|
||||
}
|
||||
|
||||
# Component 1: right_mean = -0.65, left_mean = 0.5
|
||||
# Component 1: right_mean = -0.7, left_mean = 0.5
|
||||
# right_mean < left_mean, so flip = True
|
||||
assert compute_flip_direction(1, party_scores) is True
|
||||
|
||||
@@ -39,15 +39,15 @@ def test_compute_flip_direction_right_on_right():
|
||||
"""Test that flip is False when right parties are already on the right."""
|
||||
from analysis.svd_labels import compute_flip_direction
|
||||
|
||||
# Right parties have positive scores (on right), left parties have negative
|
||||
# Right parties (PVV, FVD) have positive scores (on right), left parties have negative
|
||||
party_scores = {
|
||||
"VVD": [0.5, 0.0], # Right party, component 1 score = 0.5
|
||||
"PVV": [0.8, 0.0], # Right party
|
||||
"FVD": [0.6, 0.0], # Right party
|
||||
"SP": [-0.6, 0.0], # Left party
|
||||
"DENK": [-0.4, 0.0], # Left party
|
||||
}
|
||||
|
||||
# Component 1: right_mean = 0.65, left_mean = -0.5
|
||||
# Component 1: right_mean = 0.7, left_mean = -0.5
|
||||
# right_mean > left_mean, so flip = False
|
||||
assert compute_flip_direction(1, party_scores) is False
|
||||
|
||||
@@ -77,14 +77,14 @@ def test_auto_flip_computation_for_all_components():
|
||||
"""Test that flip directions are computed correctly for all components."""
|
||||
from analysis.svd_labels import compute_flip_direction
|
||||
|
||||
# Simulate party scores for 10 components
|
||||
# Right parties should have positive scores on component 1 (EU-integratie)
|
||||
# Simulate party scores for 10 components using CANONICAL_RIGHT/LEFT
|
||||
# Right parties should have positive scores on component 1
|
||||
# Left parties should have negative scores on component 1
|
||||
party_scores = {
|
||||
"VVD": [0.5] * 10, # Right party, positive on all components
|
||||
"PVV": [0.8] * 10, # Right party
|
||||
"SP": [-0.6] * 10, # Left party, negative on all components
|
||||
"DENK": [-0.4] * 10, # Left party
|
||||
"PVV": [0.8] * 10, # Right party (CANONICAL_RIGHT), positive on all
|
||||
"FVD": [0.6] * 10, # Right party (CANONICAL_RIGHT), positive on all
|
||||
"SP": [-0.6] * 10, # Left party (CANONICAL_LEFT), negative on all
|
||||
"DENK": [-0.4] * 10, # Left party (CANONICAL_LEFT), negative on all
|
||||
}
|
||||
|
||||
# For all components, right_mean > left_mean, so flip should be False
|
||||
@@ -94,10 +94,10 @@ def test_auto_flip_computation_for_all_components():
|
||||
|
||||
# Now test with right parties on left (negative scores)
|
||||
party_scores_left = {
|
||||
"VVD": [-0.5] * 10,
|
||||
"PVV": [-0.8] * 10,
|
||||
"SP": [0.6] * 10,
|
||||
"DENK": [0.4] * 10,
|
||||
"PVV": [-0.8] * 10, # Right party (CANONICAL_RIGHT), negative
|
||||
"FVD": [-0.6] * 10, # Right party (CANONICAL_RIGHT), negative
|
||||
"SP": [0.6] * 10, # Left party (CANONICAL_LEFT), positive
|
||||
"DENK": [0.4] * 10, # Left party (CANONICAL_LEFT), positive
|
||||
}
|
||||
|
||||
# For all components, right_mean < left_mean, so flip should be True
|
||||
|
||||
Reference in New Issue
Block a user