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.
231 lines
10 KiB
231 lines
10 KiB
---
|
|
title: "Right-Wing Party Axis Validation"
|
|
type: feat
|
|
status: completed
|
|
date: 2026-04-05
|
|
origin: 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.py` — `compute_flip_direction` at lines 127-166, uses inline `RIGHT_PARTIES`/`LEFT_PARTIES`
|
|
- `analysis/explorer_data.py` — `load_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.py` — `tmp_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:
|
|
```python
|
|
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:
|
|
```python
|
|
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
|
|
|
|
- **Origin document:** [docs/brainstorms/2026-04-05-right-wing-party-axis-validation-requirements.md](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`
|
|
|