parent
60f71ecafe
commit
54489b6a30
@ -0,0 +1,120 @@ |
|||||||
|
--- |
||||||
|
date: 2026-04-12 |
||||||
|
topic: "SVD Axis Label Alignment Fix" |
||||||
|
status: 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: |
||||||
|
|
||||||
|
```python |
||||||
|
# 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. |
||||||
Loading…
Reference in new issue