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/superpowers/specs/2026-04-02-svd-label-unific...

6.4 KiB

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

"""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
# 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:

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