diff --git a/docs/superpowers/specs/2026-04-02-svd-label-unification-design.md b/docs/superpowers/specs/2026-04-02-svd-label-unification-design.md new file mode 100644 index 0000000..5fab119 --- /dev/null +++ b/docs/superpowers/specs/2026-04-02-svd-label-unification-design.md @@ -0,0 +1,177 @@ +# SVD Label Unification Design + +## Goal + +Unify SVD component labels into a single source of truth (`SVD_THEMES`) and automatically compute axis flip directions so right-wing parties consistently appear on the right side of all SVD component axes. + +## Background + +Currently there are two separate label systems: +1. `SVD_THEMES` in `explorer.py` - defines labels for all 10 SVD components with detailed explanations +2. `_LABELS` in `axis_classifier.py` - defines short labels for the classifier (lr, eu, pi, co, pc) + +This causes duplication and potential inconsistency. Additionally, flip values are hardcoded in `SVD_THEMES` rather than computed from actual party positions. + +## Design + +### Single Source of Truth + +`SVD_THEMES` in `explorer.py` remains the canonical definition for all SVD component labels. A new shared module `analysis/svd_labels.py` will: + +1. Import `SVD_THEMES` from explorer.py (at runtime to avoid circular imports) +2. Provide helper functions to derive labels for any component +3. Compute flip direction automatically based on party centroids + +### New Module: `analysis/svd_labels.py` + +```python +"""Unified SVD component labels and automatic flip direction computation.""" + +# Canonical party sets for orientation +RIGHT_PARTIES = { + "PVV", "VVD", "FVD", "BBB", "JA21", + "Nieuw Sociaal Contract", "SGP", "CDA", "ChristenUnie" +} +LEFT_PARTIES = { + "SP", "PvdA", "GL", "GroenLinks", "GroenLinks-PvdA", + "DENK", "PvdD", "Volt" +} + +def get_svd_label(component: int) -> str: + """Get short label for SVD component (e.g., 'EU-integratie–Nationalisme').""" + +def get_svd_theme(component: int) -> dict: + """Get full theme dict for SVD component.""" + +def compute_flip_direction(component: int, party_scores: dict) -> bool: + """ + Compute flip so right parties appear on the right side. + + Args: + component: SVD component number (1-indexed) + party_scores: {party_name: [score_comp1, score_comp2, ...]} + + Returns: + True if axis should be flipped so right parties are on right. + """ + # Get scores for this component (0-indexed internally) + idx = component - 1 + right_scores = [scores[idx] for party, scores in party_scores.items() + if party in RIGHT_PARTIES and len(scores) > idx] + left_scores = [scores[idx] for party, scores in party_scores.items() + if party in LEFT_PARTIES and len(scores) > idx] + + if not right_scores or not left_scores: + return False # Default: no flip if insufficient data + + right_mean = sum(right_scores) / len(right_scores) + left_mean = sum(left_scores) / len(left_scores) + + # Flip if right parties have lower mean (they're on the left) + return right_mean < left_mean + +def get_fallback_labels() -> tuple[str, str]: + """Get fallback labels for x and y axes (components 1 and 2).""" + return (get_svd_label(1), get_svd_label(2)) +``` + +### Changes to `analysis/axis_classifier.py` + +1. Remove hardcoded `_LABELS` dict +2. Import from `svd_labels.py` at runtime (to avoid circular imports) +3. Update `display_label_for_modal` to derive from SVD_THEMES + +```python +# Remove: +_LABELS = { + "lr": "Verzorgingsstaat–Marktwerking", + "eu": "EU-integratie–Nationalisme", + ... +} + +# Add: +def _get_svd_labels(): + """Lazy import to avoid circular dependency.""" + from analysis.svd_labels import get_svd_label, get_fallback_labels + return get_fallback_labels() + +def display_label_for_modal(modal_label: Optional[str], axis: str) -> str: + """Return a user-facing axis label for a modal/internal label.""" + from analysis.svd_labels import get_svd_label + + if modal_label is None: + # Fallback to component 1 (x) or 2 (y) + comp = 1 if axis == "x" else 2 + return get_svd_label(comp) + + # Map "As 1" / "As 2" to semantic labels + if axis == "x" and modal_label in ("As 1", "Stempatroon As 1"): + return get_svd_label(1) + if axis == "y" and modal_label in ("As 2", "Stempatroon As 2"): + return get_svd_label(2) + + return modal_label +``` + +### Changes to `explorer.py` + +1. Keep `SVD_THEMES` as canonical source +2. Remove hardcoded fallback labels in `_build_political_compass_figure` +3. Use `svd_labels.py` for all label lookups +4. Add party position charts for components 3-10 in SVD Components tab + +#### Party Position Charts for All Components + +Currently, `_render_party_axis_chart` only works for components 1 and 2 (which have 2D coords). For components 3-10, we need to show 1D party positions: + +```python +def _render_party_axis_chart_1d(party_coords: dict, component: int, theme: dict, bootstrap_data: dict = None): + """Render a 1D party position chart for a single SVD component.""" + # Extract scores for this component + # Show parties on a horizontal axis + # Use theme['label'] for axis title + # Use theme['positive_pole'] and theme['negative_pole'] for annotations +``` + +### Auto-compute Flip Values + +On app startup or when loading SVD data: +1. Load party scores for all components +2. Compute flip direction for each component using `compute_flip_direction` +3. Update `SVD_THEMES[component]["flip"]` dynamically OR store precomputed values + +### Files Modified + +| File | Changes | +|------|---------| +| `analysis/svd_labels.py` | **NEW** - Unified label system with auto-flip | +| `analysis/axis_classifier.py` | Remove `_LABELS`, import from svd_labels | +| `explorer.py` | Remove fallback labels, add 1D charts for components 3-10 | +| `tests/test_axis_label_fallback.py` | Update to use new label system | +| `tests/test_political_compass.py` | Update assertions for new labels | + +### Test Plan + +1. **Unit tests for `svd_labels.py`**: + - `get_svd_label(component)` returns correct label + - `compute_flip_direction` returns correct flip based on party scores + - `get_fallback_labels()` returns tuple of component 1 and 2 labels + +2. **Integration tests**: + - Compass plot uses correct labels from SVD_THEMES + - Trajectory plot uses correct labels from SVD_THEMES + - SVD Components tab shows party positions for all components + - Right parties appear on right side of all axes + +3. **Regression tests**: + - Existing tests pass with new label system + - No circular import errors + +## Implementation Order + +1. Create `analysis/svd_labels.py` with helper functions +2. Update `axis_classifier.py` to use svd_labels +3. Update `explorer.py` to use svd_labels for fallback labels +4. Add 1D party position charts for components 3-10 +5. Update tests +6. Verify flip directions are correct for all components \ No newline at end of file