docs: add improvement roadmap, research notes, and solution docs
- Add 2026-04-24 ROADMAP with 5 phases / 17 items - Add detailed implementation plans for P1-001 through P4-005 - Add research artifacts and solution docs from ledger merge - Add test for SVD component 1 compass alignment
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
---
|
||||
title: "SVD axis labels: derive left/right from runtime flip, not static fields"
|
||||
date: 2026-04-12
|
||||
module: analysis
|
||||
problem_type: ui_bug
|
||||
component: analysis
|
||||
symptoms:
|
||||
- "SVD axis labels showed wrong orientation for components where runtime flip differed from static flip value"
|
||||
- "Right-wing parties (PVV, FVD) appeared on the LEFT side of axes despite being canonical right parties"
|
||||
- "Components 3-10 in tijdtraject view showed scores incomparable with single-window view"
|
||||
root_cause: logic_error
|
||||
resolution_type: code_fix
|
||||
severity: high
|
||||
tags:
|
||||
- svd
|
||||
- axis-labels
|
||||
- pole-labels
|
||||
- parliamentary-explorer
|
||||
- left-right-axis
|
||||
- procrustes
|
||||
---
|
||||
|
||||
# SVD Axis Labels: Derive Left/Right from Runtime Flip, Not Static Fields
|
||||
|
||||
## Problem
|
||||
SVD axis pole labels showed wrong orientation after the runtime flip mechanism was applied. Right-wing parties appeared on the LEFT side of axes despite being canonical right parties. Additionally, components 3-10 in the tijdtraject (time trajectory) view showed party scores that were incomparable with the single-window view.
|
||||
|
||||
## Symptoms
|
||||
- Axis labels like "← PVV en FVD — soevereiniteit en anti-establishment" appeared on the left side when they should be on the right
|
||||
- The flip mechanism (`compute_flip_direction`) correctly negated party scores, but labels were tied to static pre-computed fields
|
||||
- Components 3-10 in `build_svd_components_tab` used Procrustes-aligned scores that were rotated by the component 1-2 alignment, making them meaningless
|
||||
|
||||
## What Didn't Work
|
||||
The 2026-04-05 fix added static `left_pole`/`right_pole` fields to `SVD_THEMES`, pre-computed based on the static `flip` value in config. This failed because:
|
||||
|
||||
1. `compute_flip_direction()` determines flip at **runtime** by comparing mean scores of canonical right vs left parties against actual voting data
|
||||
2. The static `flip` value in config could differ from the runtime result when voting patterns shift
|
||||
3. When runtime flip differed from the static config, the pre-computed `left_pole`/`right_pole` pointed to the wrong side
|
||||
|
||||
### Root Cause Detail: Dynamic Flip Override
|
||||
|
||||
The bug was compounded by `explorer.py` lines 2636-2649, where `compute_flip_direction()` dynamically overwrites `SVD_THEMES[comp]["flip"]` for **all** components (1-10) at runtime:
|
||||
|
||||
```python
|
||||
# explorer.py lines 2677-2690
|
||||
for comp in range(1, 11):
|
||||
flip = compute_flip_direction(comp, party_scores)
|
||||
if comp in SVD_THEMES:
|
||||
SVD_THEMES[comp]["flip"] = flip
|
||||
```
|
||||
|
||||
When PVV/FVD had negative scores on component 2:
|
||||
1. `compute_flip_direction(2, party_scores)` returned `True` (right parties have lower mean)
|
||||
2. `SVD_THEMES[2]["flip"]` was overwritten from `False` to `True`
|
||||
3. With `flip=True`, scores were negated (PVV/FVD became positive → appeared on RIGHT)
|
||||
4. But the **label derivation logic** (`explorer.py` lines 954-957, 1073-1077) was backwards:
|
||||
```python
|
||||
left_label = theme.get("left_pole", pos_pole if flip else neg_pole)
|
||||
right_label = theme.get("right_pole", neg_pole if flip else pos_pole)
|
||||
```
|
||||
When `flip=True`, `left_label` was set to `pos_pole` (which described PVV/FVD), but PVV/FVD were now on the **RIGHT** side after negation.
|
||||
|
||||
This meant labels were misaligned with the actual data whenever the runtime flip differed from the static config flip.
|
||||
|
||||
## Solution
|
||||
|
||||
### Bug 1: Label derivation
|
||||
|
||||
Removed static `left_pole`/`right_pole` from all 10 `SVD_THEMES` entries in `analysis/config.py`. Labels are now always derived at render time from `positive_pole`/`negative_pole` and the runtime flip direction:
|
||||
|
||||
```python
|
||||
# analysis/svd_labels.py — derive left/right from runtime flip
|
||||
if flip:
|
||||
left_pole, right_pole = pos_pole, neg_pole # flip=True: positive on left
|
||||
else:
|
||||
left_pole, right_pole = neg_pole, pos_pole # flip=False: negative on left
|
||||
```
|
||||
|
||||
The key insight: **`negative_pole` always describes what's on the LEFT, `positive_pole` always describes what's on the RIGHT** — regardless of flip. The flip only affects which raw SVD direction maps to left vs right.
|
||||
|
||||
### Bug 2: Score mismatch in tijdtraject view
|
||||
|
||||
Changed components 3-10 in `build_svd_components_tab` from `load_party_scores_all_windows_aligned()` to `load_party_scores_all_windows()`:
|
||||
|
||||
```python
|
||||
# explorer.py — components 3-10 use per-window scores (not Procrustes-aligned)
|
||||
party_scores_by_window = load_party_scores_all_windows(db_path, all_windows)
|
||||
```
|
||||
|
||||
**Why:** Procrustes alignment rotates the full 50-dim vector space to align components 1-2 across windows, but this also transforms components 3-10, making their scores incomparable with the single-window view. Per-window flip computation already handles orientation alignment for components 3-10.
|
||||
|
||||
### Bug 3: Config as canonical SVD_THEMES source
|
||||
|
||||
Updated `analysis/svd_labels.py` to prefer `analysis.config` as the canonical source for `SVD_THEMES`, falling back to `explorer` only when config is unavailable. Config is intentionally lightweight and free of heavy runtime dependencies (duckdb, plotly).
|
||||
|
||||
### Prevention: Tests added
|
||||
|
||||
Added `tests/test_svd_axis_alignment.py` with 3 tests:
|
||||
- `test_right_wing_on_right_all_components`: Verifies canonical right parties appear on right for all 10 components
|
||||
- `test_label_derivation_matches_fallback`: Verifies label derivation logic
|
||||
- `test_config_no_deprecated_fields`: Asserts no `left_pole`/`right_pole` in config
|
||||
|
||||
Run with: `.venv/bin/python -m pytest tests/test_svd_axis_alignment.py -v`
|
||||
|
||||
## Why This Works
|
||||
The flip direction is determined by comparing canonical right vs left party average scores against actual voting data. The label derivation follows a simple rule: `negative_pole` = left, `positive_pole` = right. Since the flip operation moves the canonical right parties to the positive side, the labels always match.
|
||||
|
||||
For components 3-10, per-window scores are computed independently with per-window flip, so they remain comparable with single-window views. Procrustes only needs to align components 1-2 (the political compass axes).
|
||||
|
||||
## Prevention
|
||||
- Never add static `left_pole`/`right_pole` fields to `SVD_THEMES` — derive them at render time
|
||||
- Run `tests/test_svd_axis_alignment.py` after any SVD recomputation
|
||||
- Components 3-10 in tijdtraject view must use `load_party_scores_all_windows()`, not the aligned variant
|
||||
- The key invariant: `negative_pole` = LEFT, `positive_pole` = RIGHT — flip only determines which raw direction maps to which side
|
||||
|
||||
## Related Files
|
||||
- `analysis/config.py` — SVD_THEMES (no `left_pole`/`right_pole`)
|
||||
- `analysis/svd_labels.py` — `_get_svd_themes()` preferring config source
|
||||
- `explorer.py` — label derivation in trajectory rendering, component 3-10 scoring fix
|
||||
- `tests/test_svd_axis_alignment.py` — new tests validating alignment
|
||||
- `scripts/validate_svd_themes.py` — validation hook (updated to not expect `left_pole`/`right_pole`)
|
||||
Reference in New Issue
Block a user