---
date: 2026-04-05
topic: right-wing-party-axis-validation
---

# Right-Wing Party Axis Validation

## Problem Frame

The project convention states that PVV, FVD, JA21, and SGP must appear on the RIGHT side of all axes in visualizations (AGENTS.md). This is the #1 documented convention with zero automated enforcement. A single test prevents regression when SVD labels change or new components are added.

## Requirements

**R1. Canonical party sets defined once, imported everywhere**
- Define `CANONICAL_RIGHT = frozenset({"PVV", "FVD", "JA21", "SGP"})` in `analysis/config.py`
- Define `CANONICAL_LEFT = frozenset({"SP", "PvdA", "GL", "GroenLinks", "GroenLinks-PvdA", "DENK", "PvdD", "Volt"})` in `analysis/config.py` — matches svd_labels.py LEFT_PARTIES exactly
- All code that checks political orientation (svd_labels.py, political_axis.py) imports from config instead of defining inline

**R2. Validation test loads real data from DuckDB**
- Test file: `tests/test_axis_political_orientation.py`
- Uses existing data loading functions (`load_party_scores_all_windows_aligned` from `analysis/explorer_data.py`)
- No synthetic data — validates against actual `party_axis_scores` table

**R3. 2D political compass orientation check (statistical, not per-party)**
- `party_axis_scores` table has `x_axis_aligned` (component 1) and `y_axis_aligned` (component 2)
- For each window, validate both axes using **mean scores**:
  - **Axis 1 (x)**: Compute mean of `CANONICAL_RIGHT` x-values and mean of `CANONICAL_LEFT` x-values. Assert `right_mean > left_mean`
  - **Axis 2 (y)**: Same for y-values. Assert `right_mean > left_mean`
- "Right on right" means the **average** right party is right of the **average** left party — individual parties may deviate slightly (e.g., one right party slightly negative is fine)
- `compute_flip_direction` already implements this logic (compares group means) — use it
- Skips parties not present in a given window (graceful, not a failure)

**R4. `compute_flip_direction` consistency check**
- After loading data, call `compute_flip_direction(1, party_scores)` and `compute_flip_direction(2, party_scores)` per window
- Assert both return `False` (no flip needed) when data is already correctly oriented
- If either returns `True`, the data violates the convention and the test fails with a clear message

**R5. Clear failure messages**
- When orientation check fails, report: window, axis (x/y), right_mean, left_mean, difference
- Example: `"Window '2021-2023', x-axis: right_mean=-0.12, left_mean=0.08 (right parties on LEFT side — flip direction=True)"`

## Success Criteria

- Test runs as part of `pytest` suite (`.venv/bin/python -m pytest tests/test_axis_political_orientation.py`)
- Test passes with current data (convention currently holds — this establishes the baseline)
- If convention is violated in future data, test fails with actionable message
- Test works for all windows in the database (not just current)
- Statistical check (mean-based) — test passes even if individual parties deviate slightly from group mean

## Scope Boundaries

- **Not included**: Testing unaligned scores (only aligned scores are validated — these are what users see)
- **Not included**: VVD, NSC, BBB, CDA, ChristenUnie — these are center parties, not right-wing per AGENTS.md convention
- **Not included**: Per-party strict sign checks (statistical mean check is sufficient and more robust)
- **Not included**: Updating `political_axis.py` — R1 only updates `svd_labels.py` to import from config; `political_axis.py` uses different party sets for PCA centroid orientation and is out of scope

## Key Decisions

- **Canonical sets match AGENTS.md for right, svd_labels.py for left**: `CANONICAL_RIGHT = {PVV, FVD, JA21, SGP}` matches AGENTS.md exactly. `CANONICAL_LEFT = {SP, PvdA, GL, GroenLinks, GroenLinks-PvdA, DENK, PvdD, Volt}` matches svd_labels.py LEFT_PARTIES exactly.
- **Single unified source of truth in config.py**: `CANONICAL_RIGHT` and `CANONICAL_LEFT` frozensets go in `config.py` — it's a prerequisite for the test to work correctly. Only `svd_labels.py` is updated to import from config; `political_axis.py` is out of scope (uses party sets for PCA centroid orientation, not the same usage).
- **Aligned scores only**: Unaligned scores may vary across windows due to Procrustes alignment drift; aligned scores are the stable, user-facing representation.
- **Statistical (mean-based) validation, not per-party**: The orientation check compares group means, not individual party scores. A single right party being slightly negative is not a failure — the mean right score must exceed the mean left score.

## Dependencies / Assumptions

- DuckDB database is populated with `party_axis_scores` table with `x_axis_aligned` and `y_axis_aligned` columns (verified)
- `analysis/explorer_data.py` functions work correctly (already tested)
- `_PARTY_NORMALIZE` already exists in `config.py` (lines 247-256) — use it for party name alias normalization
- `config.py` currently lacks `CANONICAL_RIGHT`/`CANONICAL_LEFT` frozensets — these must be added as part of R1
- `compute_flip_direction()` in `svd_labels.py` currently uses inline `RIGHT_PARTIES`/`LEFT_PARTIES` — must be updated to import from config after R1

## Outstanding Questions

All resolved. Key decisions documented above.

## Next Steps
→ `/ce:plan` for structured implementation planning
