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/thoughts/shared/designs/2026-04-12-svd-axis-label-a...

6.1 KiB

date topic status
2026-04-12 SVD Axis Label Alignment Fix validated

Problem Statement

The SVD components tab has a systemic label alignment bug. The left_pole and right_pole fields in SVD_THEMES are static labels that assume a fixed flip direction. But compute_flip_direction() computes the flip dynamically per window based on party centroids. When the runtime flip differs from the static flip, labels become misaligned with the actual data.

Specific symptom: Axis 3 "marktliberaal" label appeared on the LEFT side instead of the RIGHT, because the static labels assumed flip=True but the runtime computation could return flip=False for certain windows.

Constraints

  • Right-wing parties (PVV, FVD, JA21, SGP) centroid must appear on the RIGHT side of all SVD axes
  • SVD labels are derived from voting patterns (outlier MPs + representative motions), not from ideology or party branding
  • Individual parties may vary; the centroid of right-wing parties should be on the right
  • The runtime flip direction is computed per-window by compute_flip_direction() in analysis/svd_labels.py

Approach

Remove left_pole and right_pole from all SVD_THEMES entries and simplify the rendering code to always derive labels from positive_pole, negative_pole, and runtime flip.

Why: The static labels are 100% redundant with the fallback logic. Every left_pole/right_pole in the config is identical to what the fallback would produce for the static flip value. Removing them eliminates the bug entirely and simplifies the code.

Alternatives rejected:

  • Swap labels when flip differs — More complex, requires tracking "expected flip" vs "runtime flip"
  • Store both flip directions — Doubles config size, still fragile

Architecture

Files to Change

  1. analysis/config.py — Remove left_pole and right_pole keys from all 10 SVD_THEMES entries (lines 83-84, 100-101, 119-120, 135-136, 153-154, 171-172, 191-192, 210-211, 230-231, 250-251)

  2. explorer.py lines 2809-2825 — Remove the semantic_left/semantic_right branch. The motion detail section should always derive labels from positive_pole, negative_pole, and flip, matching the fallback logic:

    # BEFORE (buggy):
    semantic_left = theme.get("left_pole") if theme else None
    semantic_right = theme.get("right_pole") if theme else None
    if semantic_left and semantic_right:
        left_pole, right_pole = semantic_left, semantic_right
        left_motions, right_motions = (
            (pos_motions, neg_motions) if flip else (neg_motions, pos_motions)
        )
        left_arrow, right_arrow = ("▲", "▼") if flip else ("▼", "▲")
    elif flip:
        ...
    
    # AFTER (fixed):
    if flip:
        left_pole, right_pole = pos_pole, neg_pole
        left_motions, right_motions = pos_motions, neg_motions
        left_arrow, right_arrow = "▲", "▼"
    else:
        left_pole, right_pole = neg_pole, pos_pole
        left_motions, right_motions = neg_motions, pos_motions
        left_arrow, right_arrow = "▼", "▲"
    
  3. explorer.py lines 969-970, 1089-1090, 1256-1257 — These already use theme.get("left_pole", fallback) with correct fallback. After removing left_pole/right_pole from config, they automatically fall back to derived values. No changes needed.

  4. scripts/validate_svd_themes.py — Update validation to remove left_pole/right_pole checks (lines 204, 281-282)

Data Flow (After Fix)

Runtime: compute_flip_direction(comp, party_scores)
    → Returns True/False based on right-wing vs left-wing centroid means
    → Stored in SVD_THEMES[comp]["flip"]

Rendering:
    flip = theme.get("flip", False)
    positive_pole = theme.get("positive_pole", "")
    negative_pole = theme.get("negative_pole", "")
    
    if flip:
        left_label = positive_pole   # Positive on left after flip
        right_label = negative_pole  # Negative on right after flip
    else:
        left_label = negative_pole  # Negative on left (standard)
        right_label = positive_pole  # Positive on right (standard)
    
    # Labels always match data because both derive from the same flip value

Components

analysis/config.py — SVD_THEMES Configuration

  • Remove left_pole and right_pole from all 10 component entries
  • Keep positive_pole, negative_pole, and flip (flip is overridden at runtime)

analysis/svd_labels.py — Label Lookup

  • get_svd_theme() returns theme dict — after removing left_pole/right_pole, callers that need display labels must derive them from positive_pole/negative_pole and flip
  • get_svd_label() returns short label — unaffected

explorer.py — Rendering

  • Motion detail section (lines ~2809-2825): Remove semantic label branch, always use flip-aware derivation
  • 1D chart (lines ~969, ~1089, ~1257): Already uses theme.get("left_pole", fallback) — will automatically fall back

scripts/validate_svd_themes.py — Validation

  • Remove left_pole/right_pole checks

Error Handling

  • If compute_flip_direction() fails (insufficient party data), returns False (no flip). Labels derive from negative_pole (left) and positive_pole (right) — correct for default orientation
  • If SVD_THEMES entry missing for a component, fallback labels use positive_pole/negative_pole with empty strings, flip-aware derivation still works
  • The except Exception: pass block in lines 2688-2690 preserves existing static flip values if runtime computation fails

Testing Strategy

  1. Unit test: Verify compute_flip_direction() returns correct values for all 10 components with known party scores
  2. Visual verification: Run uv run streamlit run Home.py, check SVD Components tab — right-wing parties (PVV, FVD, JA21, SGP) should appear on the RIGHT side of all axes
  3. Regression check: For the current window where static flip matches runtime flip, labels should be identical to before the fix
  4. Edge case: Test with a window where runtime flip differs from static flip — labels should still be correct

Open Questions

None — the fix is straightforward and complete.