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:
2026-04-05 00:42:46 +02:00
parent 5ddf2cd85a
commit 5afbad11ad
6 changed files with 1328 additions and 49 deletions
+224
View File
@@ -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
View File
@@ -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