---
title: "SVD theme divergence from actual party positions"
module: analysis
date: 2026-04-05
problem_type: logic_error
component: analysis
severity: medium
tags: [svd, themes, party-positions, validation, data-drift]
---

# SVD Theme Divergence from Actual Party Positions

## Problem

SVD axis themes in `analysis/config.py` can drift from actual party positions in `svd_vectors`. Themes are derived from subagent summaries of top motions, but party positions reflect voting on ALL motions. When the SVD is recomputed or voting patterns shift, themes may no longer match the data.

## Symptoms

- Axis 4 theme said "Mainstreampartijen versus FVD/DENK-oppositie" but actual party positions showed NSC (-24.47) and BBB (-4.58) on the left extreme, D66 (10.53)/CDA (10.11)/JA21 (9.90) on the right extreme, and FVD/DENK in the middle
- Pole labels (`left_pole`/`right_pole`) described parties that weren't actually on those sides after flip
- The flip mechanism (`compute_flip_direction`) worked correctly, but theme text was stale

## Root Cause

Themes were written manually by subagents summarizing top 20 motions per component. This captures what motions drive each axis, but party positions come from how parties voted on ALL 8,732 motions. The divergence occurs because:

1. Motion sponsors ≠ voting patterns (a motion sponsored by FVD/DENK may be voted on differently by all parties)
2. The "long tail" of motions also loads on each component and can shift party positions
3. No automated validation existed to detect when themes drift from actual data

## Solution

### 1. Fixed axis 4 theme to match actual data

Updated `analysis/config.py` component 4:

```python
# Before (wrong):
"label": "Mainstreampartijen versus FVD/DENK-oppositie",
"left_pole": "FVD en DENK: oppositieposities buiten de mainstream",
"right_pole": "Mainstreampartijen: D66, CDA, VVD, PVV, GL-PvdA, SP, Volt, 50PLUS",

# After (matches actual positions):
"label": "NSC/BBB versus D66/CDA/JA21 (indicatief)",
"left_pole": "NSC, BBB — moties met andere focus",
"right_pole": "D66, CDA, JA21 — moties met brede steun",
```

### 2. Added semantic left_pole/right_pole labels

Added `left_pole` and `right_pole` fields to all 10 SVD_THEMES entries. These describe what's on the left and right sides AFTER flip, decoupling label text from raw SVD math. Updated 4 rendering locations in `explorer.py` to use these semantic labels with backward compat fallback.

### 3. Created validation hook

Created `scripts/validate_svd_themes.py` that validates:
- Canonical right-wing parties (PVV, FVD, JA21, SGP) appear on the right side after flip
- Theme pole labels match actual party positions
- Uses full vectors for flip computation (not single-component scores)

Usage:
```bash
uv run python scripts/validate_svd_themes.py --db data/motions.db
```

Returns exit code 1 if any divergence found — suitable for CI integration.

## Why This Works

The flip mechanism (`compute_flip_direction`) correctly positions canonical right parties on the right side by comparing mean scores. The validation hook uses the same function with full average vectors to verify post-flip positions. Theme pole labels are now pre-computed semantic descriptions that match the flipped orientation, not raw SVD positive/negative poles.

## Prevention

- Run `scripts/validate_svd_themes.py` after any SVD recomputation
- Add to CI pipeline: `uv run python scripts/validate_svd_themes.py --db data/motions.db`
- When updating themes, verify against actual party positions from `svd_vectors`, not just motion sponsors
- Consider automating theme generation from party positions + motion analysis

## Related Files

- `analysis/config.py` — SVD_THEMES with left_pole/right_pole fields
- `explorer.py` — rendering functions using semantic pole labels
- `analysis/svd_labels.py` — compute_flip_direction() function
- `scripts/validate_svd_themes.py` — validation hook
