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()inanalysis/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
-
analysis/config.py— Removeleft_poleandright_polekeys 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) -
explorer.pylines 2809-2825 — Remove thesemantic_left/semantic_rightbranch. The motion detail section should always derive labels frompositive_pole,negative_pole, andflip, 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 = "▼", "▲" -
explorer.pylines 969-970, 1089-1090, 1256-1257 — These already usetheme.get("left_pole", fallback)with correct fallback. After removingleft_pole/right_polefrom config, they automatically fall back to derived values. No changes needed. -
scripts/validate_svd_themes.py— Update validation to removeleft_pole/right_polechecks (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_poleandright_polefrom all 10 component entries - Keep
positive_pole,negative_pole, andflip(flip is overridden at runtime)
analysis/svd_labels.py — Label Lookup
get_svd_theme()returns theme dict — after removingleft_pole/right_pole, callers that need display labels must derive them frompositive_pole/negative_poleandflipget_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_polechecks
Error Handling
- If
compute_flip_direction()fails (insufficient party data), returnsFalse(no flip). Labels derive fromnegative_pole(left) andpositive_pole(right) — correct for default orientation - If
SVD_THEMESentry missing for a component, fallback labels usepositive_pole/negative_polewith empty strings, flip-aware derivation still works - The
except Exception: passblock in lines 2688-2690 preserves existing static flip values if runtime computation fails
Testing Strategy
- Unit test: Verify
compute_flip_direction()returns correct values for all 10 components with known party scores - 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 - Regression check: For the current window where static flip matches runtime flip, labels should be identical to before the fix
- 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.