You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
motief/docs/plans/2026-04-05-001-feat-right-w...

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_direction consistency 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.py not updated (out of scope per requirements)

Context & Research

Relevant Code and Patterns

  • analysis/config.py — existing constants module with __all__, _PARTY_NORMALIZE at lines 247-256
  • analysis/svd_labels.pycompute_flip_direction at lines 127-166, uses inline RIGHT_PARTIES/LEFT_PARTIES
  • analysis/explorer_data.pyload_party_scores_all_windows_aligned at lines 212-241, returns {party: [[x,y] per window]}
  • analysis/trajectory.py_load_window_ids at line 121 (not exported in __all__)
  • tests/conftest.pytmp_duckdb_path fixture at line 70, tmp_duckdb_conn fixture at line 76
  • tests/test_svd_labels.py — existing tests for compute_flip_direction with 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 orientation
  • compute_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_scores rows avoids dependency on a populated real database. Follows existing pattern from test_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_NORMALIZE for alias handling: Normalize party names from DB before building party_scores dict. 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.py and 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.py scope: Not updated. Uses separate right_parties/left_parties for PCA centroid orientation, distinct concern from this validation.

Deferred to Implementation

  • Test DB schema exactness: The party_axis_scores schema (column names, nullability) should be verified against explorer_data.py query 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_PARTIES frozenset pattern at config.py line 235

Test scenarios:

  • Test expectation: none — this is a data definition change, not behavioral code

Verification:

  • CANONICAL_RIGHT and CANONICAL_LEFT accessible via from analysis.config import CANONICAL_RIGHT, CANONICAL_LEFT

  • Unit 2: Update svd_labels.py to import from config.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_PARTIES and LEFT_PARTIES frozensets 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_PARTIES from svd_labels

Patterns to follow:

  • Alias pattern (re-export) rather than removing the old names — backward compat

Test scenarios:

  • Happy path: compute_flip_direction produces same results as before (baseline established by existing tests in test_svd_labels.py)
  • Existing tests in test_svd_labels.py run and pass after the import swap

Verification:

  • pytest tests/test_svd_labels.py passes

  • Unit 3: Extract build_window_party_scores helper in explorer_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_aligned pattern at explorer_data.py line 212

Test scenarios:

  • Happy path: Given {"PVV": [[0.5, 0.3], [0.6, 0.4]], "SP": [[-0.4, -0.2], [-0.5, -0.3]]} and window_idx=0, returns {"PVV": [0.5, 0.3], "SP": [-0.4, -0.2]}
  • Edge case: window_idx=99 out 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:

  1. Synthetic fixture layer (DuckDB integration test):

    • Create temporary DB with party_axis_scores table
    • Insert controlled rows: correct orientation (right_mean > left_mean) and incorrect orientation (right_mean < left_mean)
    • Call load_party_scores_all_windows_aligned and build_window_party_scores
    • Assert orientation checks pass/fail correctly
  2. 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) → assert False (no flip needed)
      • Call compute_flip_direction(2, party_scores) → assert False
    • On failure: assert message includes window, axis, right_mean, left_mean

Use tmp_duckdb_conn fixture. Create schema and insert rows in test setup.

Patterns to follow:

  • test_analysis.py fixture setup pattern (lines 13-60) for synthetic SVD vector setup
  • test_svd_labels.py assertion style for compute_flip_direction validation

Test scenarios:

  • Happy path (correct orientation): Right mean > left mean on both axes → both compute_flip_direction calls return False
  • 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.py runs and passes
  • pytest tests/test_svd_labels.py still 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_direction output unchanged for existing callers (alias re-export)
  • API surface parity: No new public APIs; CANONICAL_RIGHT/CANONICAL_LEFT are 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