10 KiB
| title | type | status | date | origin |
|---|---|---|---|---|
| Right-Wing Party Axis Validation | feat | completed | 2026-04-05 | docs/brainstorms/2026-04-05-right-wing-party-axis-validation-requirements.md |
Right-Wing Party Axis Validation
Overview
Add automated tests that assert PVV, FVD, JA21, and SGP appear on the RIGHT side of the political compass (mean-based), using real DuckDB data. Consolidate the conflicting RIGHT_PARTIES/LEFT_PARTIES inline definitions into analysis/config.py.
Problem Frame
The AGENTS.md convention states that PVV, FVD, JA21, and SGP must appear on the RIGHT side of all axes. Three files define conflicting party sets: svd_labels.py has 9 right parties, political_axis.py has 6, and neither matches the convention. No automated validation exists.
Requirements Trace
- R1. Canonical party sets defined once, imported everywhere
- R2. Validation test loads real data from DuckDB
- R3. 2D political compass orientation check (statistical, mean-based)
- R4.
compute_flip_directionconsistency check - R5. Clear failure messages
Scope Boundaries
- Only aligned scores validated (not unaligned)
- Center parties (VVD, NSC, BBB, CDA, ChristenUnie) not validated
- Per-party strict sign checks excluded — statistical mean check
political_axis.pynot updated (out of scope per requirements)
Context & Research
Relevant Code and Patterns
analysis/config.py— existing constants module with__all__,_PARTY_NORMALIZEat lines 247-256analysis/svd_labels.py—compute_flip_directionat lines 127-166, uses inlineRIGHT_PARTIES/LEFT_PARTIESanalysis/explorer_data.py—load_party_scores_all_windows_alignedat lines 212-241, returns{party: [[x,y] per window]}analysis/trajectory.py—_load_window_idsat line 121 (not exported in__all__)tests/conftest.py—tmp_duckdb_pathfixture at line 70,tmp_duckdb_connfixture at line 76tests/test_svd_labels.py— existing tests forcompute_flip_directionwith synthetic data
Key Structural Insight
load_party_scores_all_windows_aligned returns {party: [[x, y], [x, y], ...]} — data grouped by party, not by window. To validate per window, the test must iterate window indices and build per-window dicts: {party: [x, y]} where index matches the window position.
compute_flip_direction(component, {party: [scores]}) indexes into scores[component-1], so:
compute_flip_direction(1, party_scores)checks x-axis orientationcompute_flip_direction(2, party_scores)checks y-axis orientation
Key Technical Decisions
- Synthetic DuckDB fixture data, not real DB: Temporary DB with controlled
party_axis_scoresrows avoids dependency on a populated real database. Follows existing pattern fromtest_analysis.py. - Extract window-indexing helper: A helper
build_window_party_scores(scores_by_party, window_idx)separates data transformation from DB access — enables unit testing the logic without DuckDB. _PARTY_NORMALIZEfor alias handling: Normalize party names from DB before buildingparty_scoresdict. DB may return "GL" while canonical sets expect "GroenLinks-PvdA".
Open Questions
Resolved During Planning
- DB fixture vs real DB: Use synthetic fixture data in temporary DuckDB. This is the pattern used by
test_analysis.pyand gives full control over the test scenario. - Per-window iteration: Data is
{party: [[x,y] per window]}— iterate by window index, not by key lookup. political_axis.pyscope: Not updated. Uses separateright_parties/left_partiesfor PCA centroid orientation, distinct concern from this validation.
Deferred to Implementation
- Test DB schema exactness: The
party_axis_scoresschema (column names, nullability) should be verified againstexplorer_data.pyquery at implementation time.
Implementation Units
- Unit 1: Add canonical party sets to
config.py
Goal: Add CANONICAL_RIGHT and CANONICAL_LEFT frozensets as the single source of truth.
Requirements: R1
Dependencies: None
Files:
- Modify:
analysis/config.py
Approach:
- Add
CANONICAL_RIGHT = frozenset({"PVV", "FVD", "JA21", "SGP"})matching AGENTS.md exactly - Add
CANONICAL_LEFT = frozenset({"SP", "PvdA", "GL", "GroenLinks", "GroenLinks-PvdA", "DENK", "PvdD", "Volt"})matching svd_labels.py LEFT_PARTIES exactly - Add both to
__all__
Patterns to follow:
CURRENT_PARLIAMENT_PARTIESfrozenset pattern atconfig.pyline 235
Test scenarios:
- Test expectation: none — this is a data definition change, not behavioral code
Verification:
CANONICAL_RIGHTandCANONICAL_LEFTaccessible viafrom analysis.config import CANONICAL_RIGHT, CANONICAL_LEFT
- Unit 2: Update
svd_labels.pyto import fromconfig.py
Goal: compute_flip_direction uses canonical sets from config instead of inline definitions.
Requirements: R1
Dependencies: Unit 1
Files:
- Modify:
analysis/svd_labels.py
Approach:
- Replace inline
RIGHT_PARTIESandLEFT_PARTIESfrozensets with:from analysis.config import CANONICAL_RIGHT, CANONICAL_LEFT RIGHT_PARTIES = CANONICAL_RIGHT # backward compat alias LEFT_PARTIES = CANONICAL_LEFT # backward compat alias - This preserves any external callers that import
RIGHT_PARTIES/LEFT_PARTIESfromsvd_labels
Patterns to follow:
- Alias pattern (re-export) rather than removing the old names — backward compat
Test scenarios:
- Happy path:
compute_flip_directionproduces same results as before (baseline established by existing tests intest_svd_labels.py) - Existing tests in
test_svd_labels.pyrun and pass after the import swap
Verification:
pytest tests/test_svd_labels.pypasses
- Unit 3: Extract
build_window_party_scoreshelper inexplorer_data.py
Goal: Separate window-indexing logic from DB access so it can be unit tested without DuckDB.
Requirements: R2, R3
Dependencies: None
Files:
- Create:
analysis/explorer_data.py(add function)
Approach: Add a helper:
def build_window_party_scores(
scores_by_party: Dict[str, List[List[float]]],
window_idx: int
) -> Dict[str, List[float]]:
"""Extract scores for one window as {party: [x, y]} for compute_flip_direction."""
The function takes the output of load_party_scores_all_windows_aligned and extracts scores_by_party[party][window_idx] for all parties, returning {party: [x, y]}. Returns empty dict if window_idx is out of range.
Patterns to follow:
load_party_scores_all_windows_alignedpattern atexplorer_data.pyline 212
Test scenarios:
- Happy path: Given
{"PVV": [[0.5, 0.3], [0.6, 0.4]], "SP": [[-0.4, -0.2], [-0.5, -0.3]]}andwindow_idx=0, returns{"PVV": [0.5, 0.3], "SP": [-0.4, -0.2]} - Edge case:
window_idx=99out of range → returns{} - Edge case: Empty input dict → returns
{}
Verification:
- Unit tests pass without DuckDB
- Unit 4: Create
tests/test_axis_political_orientation.py
Goal: Integration test validating political compass orientation against DuckDB data.
Requirements: R2, R3, R4, R5
Dependencies: Units 1, 2, 3
Files:
- Create:
tests/test_axis_political_orientation.py
Approach: Two-layer test structure:
-
Synthetic fixture layer (DuckDB integration test):
- Create temporary DB with
party_axis_scorestable - Insert controlled rows: correct orientation (right_mean > left_mean) and incorrect orientation (right_mean < left_mean)
- Call
load_party_scores_all_windows_alignedandbuild_window_party_scores - Assert orientation checks pass/fail correctly
- Create temporary DB with
-
Validation assertions (layered on helper from Unit 3):
- For each window (iterate
scores_by_party[party]length):- Build per-window dict via
build_window_party_scores - Call
compute_flip_direction(1, party_scores)→ assertFalse(no flip needed) - Call
compute_flip_direction(2, party_scores)→ assertFalse
- Build per-window dict via
- On failure: assert message includes window, axis, right_mean, left_mean
- For each window (iterate
Use tmp_duckdb_conn fixture. Create schema and insert rows in test setup.
Patterns to follow:
test_analysis.pyfixture setup pattern (lines 13-60) for synthetic SVD vector setuptest_svd_labels.pyassertion style forcompute_flip_directionvalidation
Test scenarios:
- Happy path (correct orientation): Right mean > left mean on both axes → both
compute_flip_directioncalls returnFalse - Error path (incorrect orientation): Right mean < left mean → at least one call returns
True, test fails with clear message - Edge case: Party not in canonical sets → gracefully skipped (no crash)
- Edge case: Empty party list → returns
False(no flip) - Edge case: Aliased party name ("GL" vs "GroenLinks-PvdA") → normalized before check
Verification:
pytest tests/test_axis_political_orientation.pyruns and passespytest tests/test_svd_labels.pystill passes (backward compat check)
System-Wide Impact
- Error propagation: No error paths in this feature — orientation violations produce assertion failures, not exceptions
- Unchanged invariants:
compute_flip_directionoutput unchanged for existing callers (alias re-export) - API surface parity: No new public APIs;
CANONICAL_RIGHT/CANONICAL_LEFTare read-only constants
Risks & Dependencies
| Risk | Mitigation |
|---|---|
| DuckDB fixture schema mismatch | Verify party_axis_scores column names against explorer_data.py query at implementation time |
| Window index boundary errors | build_window_party_scores returns {} for out-of-range indices — graceful degradation |
_PARTY_NORMALIZE aliases incomplete |
Add aliases as needed during implementation — test with edge cases |
Sources & References
- Origin document: docs/brainstorms/2026-04-05-right-wing-party-axis-validation-requirements.md
- AGENTS.md convention:
docs/solutions/best-practices/svd-labels-voting-patterns-not-semantics.md - Related code:
analysis/svd_labels.py,analysis/config.py,analysis/explorer_data.py - Related tests:
tests/test_svd_labels.py,tests/test_analysis.py