6.7 KiB
| title | date | module | problem_type | component | symptoms | root_cause | resolution_type | severity | tags |
|---|---|---|---|---|---|---|---|---|---|
| SVD axis labels: derive left/right from runtime flip, not static fields | 2026-04-12 | analysis | ui_bug | analysis | [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] | logic_error | code_fix | high | [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_tabused 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:
compute_flip_direction()determines flip at runtime by comparing mean scores of canonical right vs left parties against actual voting data- The static
flipvalue in config could differ from the runtime result when voting patterns shift - When runtime flip differed from the static config, the pre-computed
left_pole/right_polepointed 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:
# 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:
compute_flip_direction(2, party_scores)returnedTrue(right parties have lower mean)SVD_THEMES[2]["flip"]was overwritten fromFalsetoTrue- With
flip=True, scores were negated (PVV/FVD became positive → appeared on RIGHT) - But the label derivation logic (
explorer.pylines 954-957, 1073-1077) was backwards:
Whenleft_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)flip=True,left_labelwas set topos_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:
# 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():
# 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 componentstest_label_derivation_matches_fallback: Verifies label derivation logictest_config_no_deprecated_fields: Asserts noleft_pole/right_polein 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_polefields toSVD_THEMES— derive them at render time - Run
tests/test_svd_axis_alignment.pyafter 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 (noleft_pole/right_pole)analysis/svd_labels.py—_get_svd_themes()preferring config sourceexplorer.py— label derivation in trajectory rendering, component 3-10 scoring fixtests/test_svd_axis_alignment.py— new tests validating alignmentscripts/validate_svd_themes.py— validation hook (updated to not expectleft_pole/right_pole)