main
Sven Geboers 20 hours ago
parent bb8ce65ec9
commit 218a0547e3
  1. 6
      reports/overton_window/STATUS.md
  2. 4
      reports/overton_window/breakpoint_analysis.md
  3. 8
      reports/overton_window/causal_timing.md
  4. 14
      reports/overton_window/extremity_2d_temporal.md
  5. 36261
      reports/overton_window/overton_report.html
  6. 52
      reports/overton_window/overton_window.qmd
  7. 36
      reports/overton_window/overton_window_synthesis.md
  8. 857
      thoughts/blog-post-political-compass.html
  9. 58
      thoughts/blog-post-political-compass.md
  10. 190
      thoughts/blog-post-political-compass.qmd
  11. 12
      thoughts/blog-post-political-compass_files/libs/bootstrap/bootstrap-ead859a0cde6e94fc21d93203ba7f4bc.min.css
  12. 2106
      thoughts/blog-post-political-compass_files/libs/bootstrap/bootstrap-icons.css
  13. BIN
      thoughts/blog-post-political-compass_files/libs/bootstrap/bootstrap-icons.woff
  14. 7
      thoughts/blog-post-political-compass_files/libs/bootstrap/bootstrap.min.js
  15. 7
      thoughts/blog-post-political-compass_files/libs/clipboard/clipboard.min.js
  16. 9
      thoughts/blog-post-political-compass_files/libs/quarto-html/anchor.min.js
  17. 6
      thoughts/blog-post-political-compass_files/libs/quarto-html/popper.min.js
  18. 236
      thoughts/blog-post-political-compass_files/libs/quarto-html/quarto-syntax-highlighting-15634bcf2e68342d4ad2dfa704d543f6.css
  19. 845
      thoughts/blog-post-political-compass_files/libs/quarto-html/quarto.js
  20. 95
      thoughts/blog-post-political-compass_files/libs/quarto-html/tabsets/tabsets.js
  21. 1
      thoughts/blog-post-political-compass_files/libs/quarto-html/tippy.css
  22. 2
      thoughts/blog-post-political-compass_files/libs/quarto-html/tippy.umd.min.js

@ -61,7 +61,7 @@ Three tiers:
- [x] Mechanism classification (consensus framing confirmed, κ=0.41 moderate) - [x] Mechanism classification (consensus framing confirmed, κ=0.41 moderate)
- [x] Party differentiation (JA21 drives moderation, PVV entered government) - [x] Party differentiation (JA21 drives moderation, PVV entered government)
- [x] Voting margin analysis (ρ=0.812, far superior to pass rate) - [x] Voting margin analysis (ρ=0.812, far superior to pass rate)
- [x] Predictive model (AUC-ROC=0.81, RF=0.84) - [x] Predictive model (LR CV AUC-ROC=0.816, RF CV AUC-ROC=0.845, RF test=0.805)
- [x] Coalition coding fix (2024 split at July 1) - [x] Coalition coding fix (2024 split at July 1)
- [x] All-motion 2D extremity (29,591 motions, stijl=1.36, mat=2.12) - [x] All-motion 2D extremity (29,591 motions, stijl=1.36, mat=2.12)
- [x] HTML report with gravity-controlled charts + example motions - [x] HTML report with gravity-controlled charts + example motions
@ -202,8 +202,8 @@ reports/overton_window/
| Material impact (right-wing) | 2.79 | 2.45 | −0.34 | | Material impact (right-wing) | 2.79 | 2.45 | −0.34 |
| M≥4 share (% high-impact) | 23.7% | 11.3% | −12.4 pp | | M≥4 share (% high-impact) | 23.7% | 11.3% | −12.4 pp |
| SVD cultural gap | 0.282 | 0.428 | +0.146 | | SVD cultural gap | 0.282 | 0.428 | +0.146 |
| Stylistic extremity | 1.718 | 1.815 | +0.097 | | Stylistic extremity | 1.875 | 1.744 | −0.131 |
| Migration CS | 0.153 | 0.369 | +0.216 | | Migration CS (strict 4-party) | 0.134 | 0.342 | +0.208 |
| All-motion stijl | — | 1.36 | — | | All-motion stijl | — | 1.36 | — |
| All-motion materieel | — | 2.12 | — | | All-motion materieel | — | 2.12 | — |
| Stijl-materieel r (RW) | — | 0.47 | — | | Stijl-materieel r (RW) | — | 0.47 | — |

@ -71,7 +71,7 @@ Migration = category `asiel/vreemdelingen`. Non-migration = all other categories
| Domain | Pre-2024 Mean CS | Post-2024 Mean CS | Δ CS | | Domain | Pre-2024 Mean CS | Post-2024 Mean CS | Δ CS |
|--------|-----------------|------------------|------| |--------|-----------------|------------------|------|
| Migration | 0.125 | 0.343 | +0.218 | | Migration | 0.320 | 0.514 | +0.194 |
| Non-migration | 0.436 | 0.509 | +0.073 | | Non-migration | 0.436 | 0.509 | +0.073 |
## 5. Material Impact-Stratified Centrist Support ## 5. Material Impact-Stratified Centrist Support
@ -204,4 +204,4 @@ If both rose equally, a systemic factor (coalition change, polarization) is at w
## 12. Conclusion ## 12. Conclusion
*(Fill in after reviewing all indicators and audit results.)* Centrist support for right-wing motions rose from 25% to 51% after 2024, with the shift concentrated in the quarter following the PVV's November 2023 election victory. The change was uniform across material impact levels, and the timing points to an electoral rather than coalition-driven mechanism. Whether this shift persists beyond the current parliamentary term remains an open question.

@ -19,14 +19,14 @@ political events to distinguish between competing causal explanations.
**Shift velocity (4Q pre vs 4Q post):** 0.338 **Shift velocity (4Q pre vs 4Q post):** 0.338
**Shift onset relative to Schoof cabinet:** BEFORE cabinet formation **Shift onset relative to Schoof cabinet:** BEFORE cabinet formation
**Shift shape test:** **IMMEDIATE** — the structural break jump (2026-Q1 -> 2026-Q2) was +0.189, exceeding the 0.1 threshold. **Shift shape test:** **IMMEDIATE** — the main structural break jump at 2024-Q1 was +0.180 (0.321 to 0.501), exceeding the 0.1 threshold. A secondary bounce occurred at 2026-Q2 (+0.189, from 0.334 to 0.523).
- Max single-quarter jump: 0.2289 at 2020-Q4 - Max single-quarter jump: 0.2289 at 2020-Q4
- Average absolute quarterly change: 0.0999 - Average absolute quarterly change: 0.0999
- Jump ratio (max / avg): 2.29x - Jump ratio (max / avg): 2.29x
- Pre-inflection average QoQ delta: +0.0112 - Pre-inflection average QoQ delta: +0.0112
- Post-inflection average QoQ delta: +0.0024 - Post-inflection average QoQ delta: +0.0024
The largest single-quarter jump was +0.229 (2020-Q3 -> 2020-Q4). However, the **structural break** occurs at the shift onset: +0.189 (2026-Q1 -> 2026-Q2), which is 1.9x the average quarterly change (0.100). Pre-inflection spikes (e.g. 2020-Q4: +0.229) reverted within one quarter, while the 2026-Q2 structural break was **sustained** — centrist support stayed above 0.4 for 8 consecutive quarters afterward. The largest single-quarter jump was +0.229 (2020-Q3 -> 2020-Q4). However, the **main structural break** occurs at the shift onset: +0.180 at 2024-Q1, which is 1.8x the average quarterly change (0.100). Pre-inflection spikes (e.g. 2020-Q4: +0.229) reverted within one quarter, while the 2024-Q1 break was **sustained**: centrist support stayed above 0.4 for 8 consecutive quarters (2024-Q1 through 2025-Q4). A secondary bounce at 2026-Q2 (+0.189, from 0.334 to 0.523) followed a brief dip below 0.4 in 2026-Q1.
**Key insight:** The centrist support shift began ** **Key insight:** The centrist support shift began **
BEFORE the Schoof cabinet formation** (July 2024) and BEFORE the Schoof cabinet formation** (July 2024) and
@ -195,8 +195,8 @@ The adjustment was immediate upon the electoral signal (PVV victory, Nov 2023).
The centrist support surge for right-wing motions is primarily an **electoral shock phenomenon**. The centrist support surge for right-wing motions is primarily an **electoral shock phenomenon**.
The inflection point (2024-Q1) occurs in the quarter immediately following The inflection point (2024-Q1) occurs in the quarter immediately following
the PVV's November 2023 election victory. Centrist support jumped by the PVV's November 2023 election victory. Centrist support jumped by
+0.19 (2026-Q1 -> 2026-Q2) — 2x +0.180 at the main break (2023-Q4 to 2024-Q1), 1.8x
the typical quarterly variation (0.100). the typical quarterly variation (0.100). A secondary bounce of +0.189 occurred at 2026-Q2 after a brief dip below 0.4.
This rules out prominent alternative explanations: This rules out prominent alternative explanations:
- **Coalition dynamics** cannot explain it — the shift preceded cabinet formation. - **Coalition dynamics** cannot explain it — the shift preceded cabinet formation.

@ -24,7 +24,7 @@ when stylistic and material extremity scores are analyzed separately over time.
| Dimension | Pre-2024 Mean | Post-2024 Mean | Δ | | Dimension | Pre-2024 Mean | Post-2024 Mean | Δ |
|-----------|--------------|---------------|-----| |-----------|--------------|---------------|-----|
| Stylistic extremity | 1.725 | 1.797 | 0.072 | | Stylistic extremity | 1.875 | 1.744 | -0.131 |
| Material impact | 2.535 | 2.440 | -0.095 | | Material impact | 2.535 | 2.440 | -0.095 |
| Text score (original) | 2.063 | 2.190 | 0.127 | | Text score (original) | 2.063 | 2.190 | 0.127 |
| Gap (M-S) | 0.810 | 0.643 | -0.167 | | Gap (M-S) | 0.810 | 0.643 | -0.167 |
@ -110,9 +110,9 @@ and material extremity.
**Post-2024 mean r(stijl,mat):** 0.439 **Post-2024 mean r(stijl,mat):** 0.439
**Change test (Mann-Whitney):** U=10.000, p=0.571 **Change test (Mann-Whitney):** U=7.0, p=0.517
**Interpretation:** No significant change in stijl-material correlation (U=10.0, p=0.5714) **Interpretation:** No significant change in stijl-material correlation (U=7.0, p=0.517)
A significant change in the per-year stijl-material correlation would suggest A significant change in the per-year stijl-material correlation would suggest
that the relationship between the two dimensions itself shifted across the break period — that the relationship between the two dimensions itself shifted across the break period —
@ -170,7 +170,7 @@ M≥4 = motions with fundamental material impact (score ≥ 4).
| Bucket | Pre-2024 Mean Stijl | Pre-2024 Mean Mat | Post-2024 Mean Stijl | Post-2024 Mean Mat | | Bucket | Pre-2024 Mean Stijl | Pre-2024 Mean Mat | Post-2024 Mean Stijl | Post-2024 Mean Mat |
|--------|-------------------|-------------------|---------------------|-------------------| |--------|-------------------|-------------------|---------------------|-------------------|
| All RW | 1.725 | 2.535 | 1.797 | 2.440 | | All RW | 1.875 | 2.535 | 1.744 | 2.440 |
| M≥3 | 2.296 | 3.504 | 2.172 | 3.300 | | M≥3 | 2.296 | 3.504 | 2.172 | 3.300 |
| M≥4 | 2.626 | 4.183 | 2.659 | 4.154 | | M≥4 | 2.626 | 4.183 | 2.659 | 4.154 |
@ -231,8 +231,8 @@ parliamentary trends.
| Period | All Stijl | All Mat | RW Stijl | RW Mat | Stijl Δ | Mat Δ | | Period | All Stijl | All Mat | RW Stijl | RW Mat | Stijl Δ | Mat Δ |
|--------|-----------|---------|----------|--------|---------|-------| |--------|-----------|---------|----------|--------|---------|-------|
| Pre-2024 | 1.227 | 1.923 | 1.725 | 2.535 | 0.497 | 0.612 | | Pre-2024 | 1.360 | 2.120 | 1.875 | 2.535 | 0.515 | 0.415 |
| Post-2024 | 1.391 | 2.041 | 1.797 | 2.440 | 0.405 | 0.399 | | Post-2024 | 1.390 | 2.040 | 1.744 | 2.440 | 0.354 | 0.400 |
--- ---
@ -243,6 +243,6 @@ consistent with the aggregate finding of r≈0.47.
The divergence test (wilcoxon_signed_rank) found significant systematic divergence between stylistic and material yearly means (p=0.002). The divergence test (wilcoxon_signed_rank) found significant systematic divergence between stylistic and material yearly means (p=0.002).
The pre/post correlation change analysis no significant change in stijl-material correlation (u=10.0, p=0.5714). The pre/post correlation change analysis found no significant change in stijl-material correlation (U=7.0, p=0.517).
The gap (material minus stylistic) narrowed from 0.810 pre-2024 to 0.643 post-2024. The gap (material minus stylistic) narrowed from 0.810 pre-2024 to 0.643 post-2024.

File diff suppressed because one or more lines are too long

@ -50,10 +50,10 @@ The data tells a different story.
Using 29,591 Tweede Kamer motions with full MP-level vote records, Procrustes-aligned Using 29,591 Tweede Kamer motions with full MP-level vote records, Procrustes-aligned
SVD spatial analysis, and 2D extremity scoring (stijl-extremiteit vs materiële SVD spatial analysis, and 2D extremity scoring (stijl-extremiteit vs materiële
impact), we find that **the Overton window widened**: centrist support for impact), we find that **the Overton window widened**: centrist support for
right-wing motions surged from 25% to 51%, while centrist support for non-right-wing right-wing motions rose from 25% to 51%, while centrist support for non-right-wing
motions rose modestly (58%→62%, +3.5 pp). What changed was the behavior of right-wing parties: motions rose modestly (58%→62%, +3.5 pp). What changed was the behavior of right-wing parties:
they filed more motions, with milder content, framed in centrist-friendly they filed more motions, with milder content, framed in centrist-friendly
language. Centrist voting support surged from 0.251 to 0.507 (Cohen's d = +0.65), language. Centrist voting support rose from 0.251 to 0.507 (Cohen's d = +0.65),
but centrists did not become more right-wing. They stayed ideologically left but centrists did not become more right-wing. They stayed ideologically left
while voting more permissively on proposals that had become less materially while voting more permissively on proposals that had become less materially
consequential. consequential.
@ -305,14 +305,14 @@ fig2.update_layout(
fig2.show() fig2.show()
``` ```
The gravity-controlled chart reveals a critical pattern: the centrist support The gravity-controlled chart shows a consistent pattern: the centrist support
shift is real at **every** material impact level. From M=1 (mild procedural shift is real at **every** material impact level. From M=1 (mild procedural
adjustments, +0.292) to M=5 (systemic overhaul, +0.122), centrist support rose adjustments, +0.292) to M=5 (systemic overhaul, +0.122), centrist support rose
across the board. The largest absolute gains came from the middle range (M=2: across the board. The largest absolute gains came from the middle range (M=2:
+0.205, M=3: +0.219, M=4: +0.267), where most right-wing motions cluster. +0.205, M=3: +0.219, M=4: +0.267), where most right-wing motions cluster.
Comparing right-wing motions against all other motions confirms the shift is Comparing right-wing motions against all other motions confirms the shift is
specific: right-wing centrist support surged by +0.236, while non-right-wing specific: right-wing centrist support rose by +0.236, while non-right-wing
motions remained essentially flat (−0.006). This is a right-wing-specific motions remained essentially flat (−0.006). This is a right-wing-specific
phenomenon, not a general parliamentary trend. phenomenon, not a general parliamentary trend.
@ -320,11 +320,11 @@ phenomenon, not a general parliamentary trend.
Right-wing motions span ten policy categories, and the 2024 centrist support shift Right-wing motions span ten policy categories, and the 2024 centrist support shift
was not uniform across them. Every category gained centrist support, but the magnitudes was not uniform across them. Every category gained centrist support, but the magnitudes
vary dramatically — from a surge of +0.39 for climate and energy to a modest +0.08 vary dramatically, from a jump of +0.39 for climate and energy to a modest +0.08
for education and science. for education and science.
Leading the shift are **energie/klimaat** (+0.392), **buitenland/europa** (+0.364), Leading the shift are **energie/klimaat** (+0.392), **buitenland/europa** (+0.364),
and **economie** (+0.342) domains where right-wing parties adopted centrist-friendly and **economie** (+0.342), domains where right-wing parties adopted centrist-friendly
framing and right-wing governments in other European countries provided template framing and right-wing governments in other European countries provided template
policies. At the other end, **onderwijs/wetenschap** (+0.081) and **veiligheid/justitie** policies. At the other end, **onderwijs/wetenschap** (+0.081) and **veiligheid/justitie**
(+0.138) barely moved, consistent with their roles as high-consensus domains where (+0.138) barely moved, consistent with their roles as high-consensus domains where
@ -370,13 +370,13 @@ fig7.update_layout(
fig7.show() fig7.show()
``` ```
The pattern reveals a political gradient. Domains tied to European integration The pattern shows a political gradient. Domains tied to European integration
(buitenland/europa) and climate action (energie/klimaat) where center-right (buitenland/europa) and climate action (energie/klimaat), where center-right
governments abroad provided cover saw the largest shifts. Domestic social domains governments abroad provided cover, saw the largest shifts. Domestic social domains
(zorg/gezondheid, onderwijs/wetenschap) were largely insulated. The migration domain (zorg/gezondheid, onderwijs/wetenschap) were largely insulated. The migration domain
(asiel/vreemdelingen), central to the Overton narrative, ranked seventh with a (asiel/vreemdelingen), central to the Overton narrative, ranked seventh with a
+0.210 delta — substantial but not exceptional. Its importance lies not in the +0.210 delta, substantial but not exceptional. What matters is its durability
magnitude of the shift but in its durability: migration centrist support sustained rather than its size: migration centrist support sustained
its gains through 2025 while non-migration domains reverted. its gains through 2025 while non-migration domains reverted.
## Indicator 2: Spatial Divergence ## Indicator 2: Spatial Divergence
@ -438,12 +438,12 @@ patterns. The centrist center of gravity moved toward welfare and cosmopolitanis
while right-wing parties moved further into the nationalist corner. while right-wing parties moved further into the nationalist corner.
Why this makes sense with the voting data: The SVD captures the *full* Why this makes sense with the voting data: The SVD captures the *full*
voting landscape, including all motions, not just the ones centrists supported. voting record, including all motions, not just the ones centrists supported.
Right-wing parties continued filing high-impact motions that centrists opposed, Right-wing parties continued filing high-impact motions that centrists opposed,
while simultaneously filing a much larger volume of milder motions centrists while simultaneously filing a much larger volume of milder motions centrists
supported. The net effect on SVD was centrist-left divergence: the extreme supported. The net effect on SVD was centrist-left divergence: the extreme
motions (still opposed by centrists) dominated the voting structure, while the motions (still opposed by centrists) dominated the voting structure, while the
surge of milder centrist-supported motions added volume without shifting party increase in milder centrist-supported motions added volume without shifting party
positions. This is "acceptance without conversion." Centrists vote more with positions. This is "acceptance without conversion." Centrists vote more with
right-wing motions while moving further from them ideologically. right-wing motions while moving further from them ideologically.
@ -454,7 +454,7 @@ The original single-dimensional extremity score showed no increase post-2024
right-wing motions become more radical? right-wing motions become more radical?
The answer lies in what the single score measured. Two-dimensional rescoring The answer lies in what the single score measured. Two-dimensional rescoring
of all 29,591 motions reveals that stylistic extremity and material impact are of all 29,591 motions shows that stylistic extremity and material impact are
only moderately correlated (r = 0.43). When tracked separately over time, they only moderately correlated (r = 0.43). When tracked separately over time, they
tell different stories. tell different stories.
@ -544,7 +544,7 @@ AND less substantively impactful.
Compared to all motions, right-wing motions score higher on both dimensions: Compared to all motions, right-wing motions score higher on both dimensions:
stijl +0.47, materieel +0.54. The masking rate, restrained language paired stijl +0.47, materieel +0.54. The masking rate, restrained language paired
with high material impact, is 9.7% (S≤2, M≥4) or 13.5% (S=1, M≥3) for right-wing motions with high material impact, is 9.7% (S≤2, M≥4) or 13.5% (S=1, M≥3) for right-wing motions
vs 24.0% for all motions. Right-wing proposals disproportionately use vs 3.5% for all motions. Right-wing proposals disproportionately use
procedural language to advance consequential policy. procedural language to advance consequential policy.
## Mechanisms of Influence ## Mechanisms of Influence
@ -614,7 +614,7 @@ Consensus framing is significantly more common in high-support motions (24%)
than low-support (8%): χ²(1) = 6.00, p = 0.014. Exploratory evidence suggests than low-support (8%): χ²(1) = 6.00, p = 0.014. Exploratory evidence suggests
consensus framing drives centrist support. Note: inter-rater reliability for mechanism classification is moderate (κ = 0.41). These patterns are exploratory and require taxonomy refinement. consensus framing drives centrist support. Note: inter-rater reliability for mechanism classification is moderate (κ = 0.41). These patterns are exploratory and require taxonomy refinement.
**Party-level analysis** reveals the shift is not uniform. JA21 is the primary At the party level, the shift is not uniform. JA21 is the primary
driver, with a +0.203 CS shift and the only volume + support gains combination. driver, with a +0.203 CS shift and the only volume + support gains combination.
PVV entered government and filed fewer, milder motions. FVD remains structurally PVV entered government and filed fewer, milder motions. FVD remains structurally
shunned. Its motions consistently fail to gain centrist support regardless of shunned. Its motions consistently fail to gain centrist support regardless of
@ -623,8 +623,8 @@ content.
## Temporal Dynamics ## Temporal Dynamics
Quarterly analysis across 33 quarters (2016-Q2 through 2026-Q1) replaces the Quarterly analysis across 33 quarters (2016-Q2 through 2026-Q1) replaces the
binary pre/post-2024 comparison with a continuous trajectory that reveals the binary pre/post-2024 comparison with a continuous trajectory that shows the
exact timing, shape, and sustainability of the shift. exact timing and trajectory of the shift.
```{python} ```{python}
#| label: chart-6-quarterly #| label: chart-6-quarterly
@ -726,18 +726,18 @@ coalition dynamics as the primary driver. The most parsimonious explanation: cen
parties perceived the PVV's electoral success as a mandate for right-wing policy parties perceived the PVV's electoral success as a mandate for right-wing policy
and adjusted their voting behavior accordingly. However, the temporal analysis cannot fully distinguish between strategic anticipation during coalition formation and a genuine shift in centrist tolerance. and adjusted their voting behavior accordingly. However, the temporal analysis cannot fully distinguish between strategic anticipation during coalition formation and a genuine shift in centrist tolerance.
**Sustainability.** The 2026-Q1 reversion to 0.334 raises a critical question: **Sustainability.** The 2026-Q1 reversion to 0.334 raises a key question:
is the centrist support surge a temporary electoral-cycle effect rather than a is the centrist support surge a temporary electoral-cycle effect rather than a
permanent Overton window shift? Material moderation persisted (materieel ~2.4) permanent Overton window shift? Material moderation persisted (materieel ~2.4)
through the decline, but stylistic extremity reverted from 1.70 to 2.02. CS was through the decline, but stylistic extremity reverted from 1.70 to 2.02. CS was
already declining through 2025 (0.648→0.450) despite continued moderation, already declining through 2025 (0.648→0.450) despite continued moderation,
suggesting the 2024 spike was primarily an electoral shock for non-migration domains. However, 2026-Q2 shows CS bouncing back to 0.523 (n=44, interpret cautiously), driven by the intensifying migration debate. Migration centrist support (0.395) now exceeds non-migration (0.368) for the first time. The shift is domain-specific: temporary for non-migration, durable for migration. which points to the 2024 spike as primarily an electoral shock for non-migration domains. However, 2026-Q2 shows CS bouncing back to 0.523 (n=44, interpret cautiously), driven by the intensifying migration debate. Migration centrist support (0.395) now exceeds non-migration (0.368) for the first time. The shift is domain-specific: temporary for non-migration, durable for migration.
| Hypothesis | Evidence | Verdict | | Hypothesis | Evidence | Verdict |
|------------|----------|---------| |------------|----------|---------|
| Electoral shock | Jump immediately followed PVV victory (Nov 2023) | **Supported** | | Electoral shock | Jump immediately followed PVV victory (Nov 2023) | **Supported** |
| Coalition dynamics | Shift began 3 quarters before cabinet formed | **Less consistent with the data** | | Coalition dynamics | Shift began 3 quarters before cabinet formed | **Less consistent with the data** |
| Gradual learning | Jump was 1.9× average quarterly — discrete, not incremental | **Less consistent with the data** | | Gradual learning | Jump was 1.9× average quarterly, discrete rather than incremental | **Less consistent with the data** |
| European contagion | No Dutch response during 2022–2023 European shift | **Less consistent with the data** | | European contagion | No Dutch response during 2022–2023 European shift | **Less consistent with the data** |
```{python} ```{python}
@ -823,11 +823,11 @@ fig8.show()
acceptable after 2024. But the mechanism was right-wing moderation, not centrist acceptable after 2024. But the mechanism was right-wing moderation, not centrist
conversion, and the effect may be temporary.** conversion, and the effect may be temporary.**
Centrist support for right-wing motions surged from 25% to 51%, while centrist Centrist support for right-wing motions rose from 25% to 51%, while centrist
support for non-right-wing motions rose modestly (58%→62%, +3.5 pp). The window of acceptable support for non-right-wing motions rose modestly (58%→62%, +3.5 pp). The window of acceptable
debate expanded rightward. debate expanded rightward.
1. **Volume surged, impact declined.** Right-wing motions doubled in volume 1. **Volume doubled, impact declined.** Right-wing motions doubled in volume
post-2024, but material impact fell from 2.79 to 2.45 (Cohen's d = −0.36). post-2024, but material impact fell from 2.79 to 2.45 (Cohen's d = −0.36).
The M ≥ 4 share dropped from 23.7% to 11.3% and continued falling to 2.7% The M ≥ 4 share dropped from 23.7% to 11.3% and continued falling to 2.7%
by 2026. by 2026.
@ -847,7 +847,7 @@ debate expanded rightward.
extreme tail polarized even as cooperation grew on the moderate mass. extreme tail polarized even as cooperation grew on the moderate mass.
5. **The shift is electorally driven and domain-specific.** Centrist support 5. **The shift is electorally driven and domain-specific.** Centrist support
surged immediately after the PVV election, peaked at 0.648 in 2024-Q4, and jumped immediately after the PVV election, peaked at 0.648 in 2024-Q4, and
declined through 2025 to 0.450 despite continued material moderation. Then declined through 2025 to 0.450 despite continued material moderation. Then
hit 0.334 in 2026-Q1. But 2026-Q2 bounced back to 0.523 (n=44, interpret cautiously), driven by the hit 0.334 in 2026-Q1. But 2026-Q2 bounced back to 0.523 (n=44, interpret cautiously), driven by the
intensifying migration debate. Non-migration acceptance was a temporary intensifying migration debate. Non-migration acceptance was a temporary
@ -856,7 +856,7 @@ debate expanded rightward.
The gateway domain: migration. Migration is where the Overton shift is most The gateway domain: migration. Migration is where the Overton shift is most
genuine. The frames right-wing parties learned there, they then applied genuine. The frames right-wing parties learned there, they then applied
elsewhere. Material impact barely declined (−0.13), yet centrist support more elsewhere. Material impact barely declined (−0.13), yet centrist support more
than doubled (0.153 → 0.369). Centrists went from zero support for M = 5 than doubled (0.134 to 0.342). Centrists went from zero support for M = 5
migration motions to nearly 20%. The gradient between impact levels flattened. migration motions to nearly 20%. The gradient between impact levels flattened.
Centrists became willing to support migration motions at every severity level. Centrists became willing to support migration motions at every severity level.
This is measurable acceptance expansion, This is measurable acceptance expansion,
@ -864,7 +864,7 @@ driven primarily by CDA and ChristenUnie rather than D66. What started as a
migration-specific acceptance shift became the template for broader Overton migration-specific acceptance shift became the template for broader Overton
widening across climate, security, and economic policy. As of 2026, migration widening across climate, security, and economic policy. As of 2026, migration
centrist support (0.395) exceeds non-migration (0.368) for the first time, centrist support (0.395) exceeds non-migration (0.368) for the first time,
confirming that migration acceptance is durable while non-migration acceptance which indicates that migration acceptance is durable while non-migration acceptance
was the temporary component. Multiple 2026-Q2 migration motions received was the temporary component. Multiple 2026-Q2 migration motions received
unanimous centrist support (CS = 1.00), including high-impact items. unanimous centrist support (CS = 1.00), including high-impact items.

@ -10,18 +10,18 @@
| Indicator | Pre-2024 | Post-2024 | Δ | Verdict | | Indicator | Pre-2024 | Post-2024 | Δ | Verdict |
|-----------|----------|-----------|---|--------| |-----------|----------|-----------|---|--------|
| Centrist support (strict) | 0.251 | 0.507 | +0.256 | **Surged** | | Centrist support (strict) | 0.251 | 0.507 | +0.256 | **Rose** |
| Material impact (2D) | 2.79 | 2.45 | −0.34 | **Declined** | | Material impact (2D) | 2.79 | 2.45 | −0.34 | **Declined** |
| M≥4 share (% high-impact) | 23.7% | 11.3% | −12.4 pp | **Declined** | | M≥4 share (% high-impact) | 23.7% | 11.3% | −12.4 pp | **Declined** |
| SVD cultural gap (centrist−right) | 0.282 | 0.428 | +0.146 | **Diverged** | | SVD cultural gap (centrist−right) | 0.282 | 0.428 | +0.146 | **Diverged** |
| Stylistic extremity (2D) | 1.875 | 1.744 | −0.131 | **Declined** | | Stylistic extremity (2D) | 1.875 | 1.744 | −0.131 | **Declined** |
| Temporal trajectory | - | - | - | **Immediate electoral jump, reverting** | | Temporal trajectory | - | - | - | **Immediate electoral jump, reverting** |
Centrist support surged. Centrist parties moved *left* spatially while voting *more* with right-wing motions. But the motions themselves became *less* materially impactful. The share of high-impact proposals (M≥4) dropped from 23.7% to 11.3% and continued falling through 2026 (2.7%). The Overton window **widened**: more right-wing positions became politically acceptable. Right-wing parties shifted their strategy toward the window: they filed more motions, with milder content, framed in centrist-friendly language. The center rewarded the framing without moving ideologically. Centrist support rose. Centrist parties moved *left* spatially while voting *more* with right-wing motions. But the motions themselves became *less* materially impactful. The share of high-impact proposals (M≥4) dropped from 23.7% to 11.3% and continued falling through 2026 (2.7%). The Overton window **widened**: more right-wing positions became politically acceptable. Right-wing parties shifted their strategy toward the window: they filed more motions, with milder content, framed in centrist-friendly language. The center rewarded the framing without moving ideologically.
Two additional findings deepen the picture. First, the 2D extremity decomposition shows both dimensions declined: stylistic extremity fell (−0.131) alongside material impact (−0.35). Right-wing motions became less rhetorically hostile AND less materially consequential, a holistic moderation strategy, not just surface-level repackaging. Second, the temporal trajectory reveals the shift was an **immediate electoral jump** (+0.180 in a single quarter) that peaked at 0.648 in 2024-Q4 and has since reverted to 0.334 by 2026-Q1. The shift may be an electoral-cycle phenomenon rather than a permanent Overton window movement. Two additional findings deepen the picture. First, the 2D extremity decomposition shows both dimensions declined: stylistic extremity fell (−0.131) alongside material impact (−0.35). Right-wing motions became less rhetorically hostile AND less materially consequential, a across-the-board moderation strategy, not just surface-level repackaging. Second, the temporal trajectory reveals the shift was an **immediate electoral jump** (+0.180 in a single quarter) that peaked at 0.648 in 2024-Q4 and has since reverted to 0.334 by 2026-Q1. The shift may be an electoral-cycle phenomenon rather than a permanent Overton window movement.
This is **acceptance through moderation**, not acceptance through conversion. Right-wing influence grew by becoming more centrist-compatible, not by making centrists more right-wing. This is acceptance through moderation. Right-wing influence grew because right-wing parties became more centrist-compatible, not because centrists became more right-wing.
--- ---
@ -45,17 +45,17 @@ The aggregate shift masks two distinct stories. Breaking the data by policy doma
|--------|--------|---------|----------|-----------|---------| |--------|--------|---------|----------|-----------|---------|
| Non-migration (all) | 0.268 | 0.534 | 20.8% | 8.0% | Moderation dominates | | Non-migration (all) | 0.268 | 0.534 | 20.8% | 8.0% | Moderation dominates |
| Climate/stikstof/energy | 0.303 | 0.554 | 26.3% | 6.3% | Strong moderation | | Climate/stikstof/energy | 0.303 | 0.554 | 26.3% | 6.3% | Strong moderation |
| **Migration (asiel)** | **0.153** | **0.369** | **44.1%** | **28.9%** | **Mixed: acceptance + moderation** | | **Migration (asiel)** | **0.134** | **0.342** | **44.1%** | **28.9%** | **Mixed: acceptance + moderation** |
**Non-migration (85% of motions):** The story is clear strategic moderation. Right-wing parties doubled motion volume while halving the share of high-impact proposals (M≥4: 20.8%→8.0%). They shifted from system-level abolition to operational adjustments, specifically targeted rule changes rather than framework destruction. Example: pre-2024 motions demanded "abolish all nitrogen policy" or "exit the Paris climate accord" (M=5, CS=0.0 every time). Post-2024 motions propose "build four nuclear plants" or "create a methane-reduction feed agreement with farmers" (M=2-4, CS=1.0). Centrists rewarded the operational framing. **Non-migration (85% of motions):** The story is clear strategic moderation. Right-wing parties doubled motion volume while halving the share of high-impact proposals (M≥4: 20.8%→8.0%). They shifted from system-level abolition to operational adjustments, specifically targeted rule changes rather than framework destruction. Example: pre-2024 motions demanded "abolish all nitrogen policy" or "exit the Paris climate accord" (M=5, CS=0.0 every time). Post-2024 motions propose "build four nuclear plants" or "create a methane-reduction feed agreement with farmers" (M=2-4, CS=1.0). Centrists rewarded the operational framing.
**Migration (15% of motions):** The pattern is different. Material impact barely changed (3.26→3.13, only −0.13), yet centrist support more than doubled (0.153→0.369). Centrists went from *never* supporting M=5 migration motions (CS=0.000) to backing nearly 1 in 5 (CS=0.185). The gradient between impact levels flattened significantly. Centrists still differentiate, but the gap narrowed. This is the one domain where genuine acceptance expansion (not just content moderation) is measurable. **Migration (15% of motions):** The pattern is different. Material impact barely changed (3.26 to 3.13, only -0.13), yet centrist support more than doubled (0.134 to 0.342). Centrists went from *never* supporting M=5 migration motions (CS=0.000) to backing nearly 1 in 5 (CS=0.185). The gradient between impact levels flattened significantly. Centrists still differentiate, but the gap narrowed. This is the one domain where genuine acceptance expansion (not just content moderation) is measurable.
### Temporal Dynamics ### Temporal Dynamics
Quarterly analysis across 33 quarters (2016-Q2 through 2026-Q1) replaces the binary pre/post-2024 comparison with a continuous trajectory that reveals the exact timing, shape, and sustainability of the shift. Quarterly analysis across 33 quarters (2016-Q2 through 2026-Q1) replaces the binary pre/post-2024 comparison with a continuous trajectory that shows the exact timing and trajectory of the shift.
**Timing.** The inflection point is 2024-Q1, the quarter immediately following the PVV's November 2023 election victory. Centrist support jumped from 0.321 (2023-Q4) to 0.501 (2024-Q1), a single-quarter increase of +0.180, roughly twice the average quarterly change of 0.097. This was a discrete structural break, not a gradual ramp. The pre-inflection mean (0.329 across 24 quarters) was stable and low. The post-inflection mean (0.514 across 9 quarters) is substantially higher, but the trajectory within the post-inflection period tells a more nuanced story. **Timing.** The inflection point is 2024-Q1, the quarter immediately following the PVV's November 2023 election victory. Centrist support jumped from 0.321 (2023-Q4) to 0.501 (2024-Q1), a single-quarter increase of +0.180, roughly twice the average quarterly change of 0.097. This was a discrete structural break, not a gradual ramp. The pre-inflection mean (0.329 across 24 quarters) was stable and low. The post-inflection mean (0.514 across 9 quarters) is substantially higher, but the trajectory within the post-inflection period is more complicated.
**Shape.** Centrist support rose sharply from 2024-Q1 through 2024-Q4, reaching an all-time peak of 0.648 in the first full quarter of the Schoof cabinet. From that peak, it declined steadily: 0.598 in 2025-Q1, 0.503 in 2025-Q2, 0.437 in 2025-Q3, 0.450 in 2025-Q4, and 0.334 in 2026-Q1, below the 0.4 inflection threshold. The peak-to-current decline of 0.314 is larger in magnitude than the original pre-to-peak surge of 0.327. **Shape.** Centrist support rose sharply from 2024-Q1 through 2024-Q4, reaching an all-time peak of 0.648 in the first full quarter of the Schoof cabinet. From that peak, it declined steadily: 0.598 in 2025-Q1, 0.503 in 2025-Q2, 0.437 in 2025-Q3, 0.450 in 2025-Q4, and 0.334 in 2026-Q1, below the 0.4 inflection threshold. The peak-to-current decline of 0.314 is larger in magnitude than the original pre-to-peak surge of 0.327.
@ -70,7 +70,7 @@ Quarterly analysis across 33 quarters (2016-Q2 through 2026-Q1) replaces the bin
The most parsimonious explanation is that centrist parties perceived the PVV's electoral success as a mandate for right-wing policy and adjusted their voting behavior accordingly, even before the new cabinet was formed. Strategic moderation may have reinforced the shift once underway, but the trigger was electoral, not strategic. The temporal analysis rules out mechanical coalition effects but cannot distinguish between strategic anticipation during formation negotiations and a genuine shift in centrist tolerance. The causal mechanism remains underdetermined. The most parsimonious explanation is that centrist parties perceived the PVV's electoral success as a mandate for right-wing policy and adjusted their voting behavior accordingly, even before the new cabinet was formed. Strategic moderation may have reinforced the shift once underway, but the trigger was electoral, not strategic. The temporal analysis rules out mechanical coalition effects but cannot distinguish between strategic anticipation during formation negotiations and a genuine shift in centrist tolerance. The causal mechanism remains underdetermined.
**Sustainability.** The trajectory is domain-specific. The 2026-Q1 reversion (CS = 0.334) suggested the shift was temporary. Material moderation persisted (materieel ~2.4) but stylistic moderation reverted (stijl 1.70→2.02), and centrist support was declining through 2025 (0.648→0.450). However, 2026-Q2 shows a significant bounce back to CS = 0.523 (N=44), driven by the intensifying migration debate. For the first time, migration-domain centrist support (0.395) exceeds non-migration (0.368) in 2026, a structural reversal from the historical pattern where migration was always the lowest-acceptance domain. Multiple 2026-Q2 migration motions received unanimous centrist support (CS=1.00), including high-impact (M=4) items. This suggests the Overton shift is not uniformly temporary: non-migration acceptance was an electoral shock response that faded, but migration acceptance appears durable and growing. The "acceptance through moderation" thesis describes the 2024 mechanism for non-migration domains, but migration has become self-sustaining as a political priority that transcends partisan framing. **Sustainability.** The trajectory is domain-specific. The 2026-Q1 reversion (CS = 0.334) suggested the shift was temporary. Material moderation persisted (materieel ~2.4) but stylistic moderation reverted (stijl 1.70→2.02), and centrist support was declining through 2025 (0.648→0.450). However, 2026-Q2 shows a significant bounce back to CS = 0.523 (N=44), driven by the intensifying migration debate. For the first time, migration-domain centrist support (0.395) exceeds non-migration (0.368) in 2026, a structural reversal from the historical pattern where migration was always the lowest-acceptance domain. Multiple 2026-Q2 migration motions received unanimous centrist support (CS=1.00), including high-impact (M=4) items. This suggests the Overton shift is not uniformly temporary: non-migration acceptance was an electoral shock response that faded, but migration acceptance appears durable and growing. The "acceptance through moderation" thesis describes the 2024 mechanism for non-migration domains, but migration has become self-sustaining as a political priority that crosses partisan lines.
### Who Drove the Shift? MP-Level Granularity ### Who Drove the Shift? MP-Level Granularity
@ -102,7 +102,7 @@ This is spatial *divergence*, not convergence. Centrist parties did not become r
**Why this makes sense with the material impact data:** The SVD captures the *full* voting record, including all motions, not just the ones centrists supported. Right-wing parties continued filing high-impact motions that centrists opposed, while simultaneously filing a much larger volume of milder motions centrists supported. The net effect on SVD was centrist-left divergence: the extreme motions (still opposed by centrists) dominated the voting structure, while the surge of milder centrist-supported motions added volume without shifting party positions. **Why this makes sense with the material impact data:** The SVD captures the *full* voting record, including all motions, not just the ones centrists supported. Right-wing parties continued filing high-impact motions that centrists opposed, while simultaneously filing a much larger volume of milder motions centrists supported. The net effect on SVD was centrist-left divergence: the extreme motions (still opposed by centrists) dominated the voting structure, while the surge of milder centrist-supported motions added volume without shifting party positions.
The tension between greater voting support and greater ideological distance is the puzzle that the mechanism analysis resolves. The next section explains how greater voting support coexists with greater ideological distance.
**An important caveat:** SVD spatial positions capture *voting patterns*, not motion content or stated ideology. The finding that centrists moved left on the SVD axes means centrist parties' voting patterns became more distinct from right-wing voting patterns. It does not tell us whether the motions themselves became more right-wing or left-wing in content. A right-wing motion can score as "far right" on SVD because right-wing parties voted uniformly for it and left-wing parties uniformly against it, while the motion's textual content may be moderate. Conversely, a motion on a topic centrists and right-wing parties agree on (e.g., defense spending, nuclear energy) would show little spatial separation regardless of how radical the motion text is. SVD measures agreement structure, not policy positions. The "acceptance without conversion" framework is therefore a claim about *voting behavior*, not about party manifestos or deputies' stated beliefs. **An important caveat:** SVD spatial positions capture *voting patterns*, not motion content or stated ideology. The finding that centrists moved left on the SVD axes means centrist parties' voting patterns became more distinct from right-wing voting patterns. It does not tell us whether the motions themselves became more right-wing or left-wing in content. A right-wing motion can score as "far right" on SVD because right-wing parties voted uniformly for it and left-wing parties uniformly against it, while the motion's textual content may be moderate. Conversely, a motion on a topic centrists and right-wing parties agree on (e.g., defense spending, nuclear energy) would show little spatial separation regardless of how radical the motion text is. SVD measures agreement structure, not policy positions. The "acceptance without conversion" framework is therefore a claim about *voting behavior*, not about party manifestos or deputies' stated beliefs.
@ -136,7 +136,7 @@ A Wilcoxon signed-rank test comparing yearly mean stylistic vs yearly mean mater
Domain-stratified analysis reveals the same pattern in both migration and non-migration motions. In migration, stylistic scores dropped from 2.70 to 2.51 while material declined from 3.27 to 3.04. Both fell, with style falling faster. In non-migration, stylistic scores remained essentially flat (1.65→1.69) while material fell substantially (2.48→2.25). The per-year correlation between stylistic and material scores did not significantly change (Mann-Whitney U=9.0, p=0.79), suggesting the two dimensions have been consistently only moderately correlated throughout the entire period. This is not a new phenomenon triggered by the 2024 shift. Domain-stratified analysis reveals the same pattern in both migration and non-migration motions. In migration, stylistic scores dropped from 2.70 to 2.51 while material declined from 3.27 to 3.04. Both fell, with style falling faster. In non-migration, stylistic scores remained essentially flat (1.65→1.69) while material fell substantially (2.48→2.25). The per-year correlation between stylistic and material scores did not significantly change (Mann-Whitney U=9.0, p=0.79), suggesting the two dimensions have been consistently only moderately correlated throughout the entire period. This is not a new phenomenon triggered by the 2024 shift.
The practical implication: right-wing motions post-2024 are both less rhetorically hostile AND less substantively impactful. The strategic shift is holistic: it affects both the packaging and the content of what right-wing parties propose, not just how they say it. The practical implication: right-wing motions post-2024 are both less rhetorically hostile AND less substantively impactful. The strategic shift affects both the packaging and the content of what right-wing parties propose, not just how they say it.
--- ---
@ -184,7 +184,7 @@ Consensus framing (appealing to shared values: safety, efficiency, pragmatism, g
The mechanism × period interaction is significant (χ²(9) = 28.55, p < 0.001), indicating the distribution of mechanism types changed between periods. The largest shifts: The mechanism × period interaction is significant (χ²(9) = 28.55, p < 0.001), indicating the distribution of mechanism types changed between periods. The largest shifts:
- **Institutioneel/rechtsstatelijk:** surging from 4.0% to 17.3% (+13.3 pp), mostly in *low*-support motions, indicating right-wing institutional critique increased but did not gain centrist acceptance. - **Institutioneel/rechtsstatelijk:** rising from 4.0% to 17.3% (+13.3 pp), mostly in *low*-support motions, indicating right-wing institutional critique increased but did not gain centrist acceptance.
- **Crisisrespons:** collapsing from 14.0% to 0.7% (−13.3 pp). Right-wing parties abandoned crisis-framed motions. - **Crisisrespons:** collapsing from 14.0% to 0.7% (−13.3 pp). Right-wing parties abandoned crisis-framed motions.
- **Gerichte restrictie:** rising from 14.0% to 22.7% (+8.7 pp). Targeted rights restrictions grew in both high- and low-support categories, but remain the dominant mechanism in low-support motions. - **Gerichte restrictie:** rising from 14.0% to 22.7% (+8.7 pp). Targeted rights restrictions grew in both high- and low-support categories, but remain the dominant mechanism in low-support motions.
@ -241,11 +241,11 @@ The ceiling effect is the dominant methodological reality: when 96%+ of motions
**The Overton window widened: more right-wing positions became politically acceptable after 2024. The widening was primarily driven by an electoral shock, with content moderation as a contributing factor. The shift is domain-specific: temporary for non-migration domains, but durable and growing for migration.** **The Overton window widened: more right-wing positions became politically acceptable after 2024. The widening was primarily driven by an electoral shock, with content moderation as a contributing factor. The shift is domain-specific: temporary for non-migration domains, but durable and growing for migration.**
Centrist support for right-wing motions surged from 25% to 51%, while centrist support for non-right-wing motions rose only modestly (58%→62%, +3.5 pp). The window of acceptable debate expanded disproportionately for right-wing content. What changed was not what centrists found acceptable. It was what right-wing parties chose to propose: Centrist support for right-wing motions rose from 25% to 51%, while centrist support for non-right-wing motions rose only modestly (58% to 62%, +3.5 pp). The window of acceptable debate expanded disproportionately for right-wing content. What changed was not what centrists found acceptable. It was what right-wing parties chose to propose:
1. Motion volume surged, impact declined. Right-wing motions doubled in volume post-2024, but became measurably milder. Material impact fell from 2.79 to 2.45 (motion-level means). The share of M≥4 proposals dropped from 23.7% to 11.3% and continued falling through 2026. The 2D extremity decomposition confirms both dimensions declined. Stylistic extremity fell (1.875→1.744) alongside material impact, consistent with holistic moderation of content, not just repackaging of radical substance. 1. Motion volume doubled, impact declined. Right-wing motions doubled in volume post-2024, but became measurably milder. Material impact fell from 2.79 to 2.45 (motion-level means). The share of M≥4 proposals dropped from 23.7% to 11.3% and continued falling through 2026. The 2D extremity decomposition confirms both dimensions declined. Stylistic extremity fell (1.875→1.744) alongside material impact, consistent with moderation of both content and framing, not just repackaging of radical substance.
2. Centrists did not become more tolerant. The extremity-stratified centrist support gradient persists. Centrists still differentiate between mild and extreme motions post-2024. The across-the-board +0.25 baseline shift reflects that *the content within each bucket became milder on average*, not that centrists lowered their standards. The left-wing response confirms the asymmetry: centrist support surged by +20.6 pp while left-wing opposition barely changed (−1.1 pp), ruling out "left-wing hardening" as an alternative explanation. 2. Centrists did not become more tolerant. The extremity-stratified centrist support gradient persists. Centrists still differentiate between mild and extreme motions post-2024. The across-the-board +0.25 baseline shift reflects that *the content within each bucket became milder on average*, not that centrists lowered their standards.
3. The mechanism is strategic moderation. Exploratory evidence suggests this is the dominant pathway. The 200-motion mechanism classification found zero system-dismantling proposals among high-centrist-support post-2024 motions. The dominant pathways (procedural/technical at 32%, consensus framing at 24%, and targeted restriction at 17%) show right-wing parties learned which frames work. Consensus framing is significantly more common in high-support than low-support motions (χ²=6.0, p=0.014). This confirms and extends the original 24-motion qualitative finding with a structured, stratified sample. 3. The mechanism is strategic moderation. Exploratory evidence suggests this is the dominant pathway. The 200-motion mechanism classification found zero system-dismantling proposals among high-centrist-support post-2024 motions. The dominant pathways (procedural/technical at 32%, consensus framing at 24%, and targeted restriction at 17%) show right-wing parties learned which frames work. Consensus framing is significantly more common in high-support than low-support motions (χ²=6.0, p=0.014). This confirms and extends the original 24-motion qualitative finding with a structured, stratified sample.
@ -253,13 +253,13 @@ Centrist support for right-wing motions surged from 25% to 51%, while centrist s
5. The shift is electorally driven and domain-specific. Quarterly trajectory data shows the centrist support surge was an immediate electoral response to the PVV's November 2023 victory, jumping +0.180 in a single quarter, before the Schoof cabinet formed. Coalition dynamics, gradual learning, and European contagion are less consistent with the timing. Centrist support peaked at 0.648 (2024-Q4), declined through 2025, and hit 0.334 in 2026-Q1, suggesting an electoral-cycle effect. However, 2026-Q2 shows a bounce to 0.523 (n=44, bimodal distribution; interpret cautiously), driven partly by the intensifying migration debate. The reversion was real for non-migration domains (where the shock response faded), but migration acceptance has become self-sustaining and is now the dominant driver of continued Overton widening. 5. The shift is electorally driven and domain-specific. Quarterly trajectory data shows the centrist support surge was an immediate electoral response to the PVV's November 2023 victory, jumping +0.180 in a single quarter, before the Schoof cabinet formed. Coalition dynamics, gradual learning, and European contagion are less consistent with the timing. Centrist support peaked at 0.648 (2024-Q4), declined through 2025, and hit 0.334 in 2026-Q1, suggesting an electoral-cycle effect. However, 2026-Q2 shows a bounce to 0.523 (n=44, bimodal distribution; interpret cautiously), driven partly by the intensifying migration debate. The reversion was real for non-migration domains (where the shock response faded), but migration acceptance has become self-sustaining and is now the dominant driver of continued Overton widening.
**The gateway domain: migration.** The asylum/migration domain is where the Overton shift is most genuine, and where right-wing parties learned the frames they then applied elsewhere. Material impact barely declined (−0.13), yet centrist support more than doubled (0.153→0.369). Centrists went from zero support for M=5 migration motions to nearly 20%. The gradient between impact levels flattened. Centrists became willing to support migration motions at every severity level. This is not just strategic moderation: it is measurable acceptance expansion, driven primarily by CDA and ChristenUnie rather than D66. Migration is also the domain where right-wing parties first perfected the consensus framing and institutional appeals that later spread to climate, security, and economic policy. What started as a migration-specific acceptance shift became the template for the broader Overton widening. As of 2026, migration centrist support (0.395) exceeds non-migration (0.368) for the first time, a structural reversal confirming that migration acceptance is durable and growing while non-migration acceptance was the temporary electoral shock response. Multiple 2026-Q2 migration motions received unanimous centrist support (CS=1.00), including high-impact items, as the Dutch migration debate intensified. **The gateway domain: migration.** The asylum/migration domain is where the Overton shift is most genuine, and where right-wing parties learned the frames they then applied elsewhere. Material impact barely declined (−0.13), yet centrist support more than doubled (0.134 to 0.342). Centrists went from zero support for M=5 migration motions to nearly 20%. The gradient between impact levels flattened. Centrists became willing to support migration motions at every severity level. This is not just strategic moderation: it is measurable acceptance expansion, driven primarily by CDA and ChristenUnie rather than D66. Migration is also the domain where right-wing parties first perfected the consensus framing and institutional appeals that later spread to climate, security, and economic policy. What started as a migration-specific acceptance shift became the template for the broader Overton widening. As of 2026, migration centrist support (0.395) exceeds non-migration (0.368) for the first time, a structural reversal confirming that migration acceptance is durable and growing while non-migration acceptance was the temporary electoral shock response. Multiple 2026-Q2 migration motions received unanimous centrist support (CS=1.00), including high-impact items, as the Dutch migration debate intensified.
### Uncertainty Hierarchy ### Uncertainty Hierarchy
| Level | Finding | Status | | Level | Finding | Status |
|-------|---------|--------| |-------|---------|--------|
| **Strong** | Centrist voting support surged (d = +0.65 strict, d = +0.85 opposition-only) | Confirmed | | **Strong** | Centrist voting support rose (d = +0.65 strict, d = +0.85 opposition-only) | Confirmed |
| **Strong** | Material impact of right-wing motions *declined* post-2024 (2.79→2.45 motion-level, M≥4 share: 23.7%→11.3%) | Confirmed on n=3,030 | | **Strong** | Material impact of right-wing motions *declined* post-2024 (2.79→2.45 motion-level, M≥4 share: 23.7%→11.3%) | Confirmed on n=3,030 |
| **Strong** | SVD spatial divergence, centrists moved left, right moved further right | Confirmed | | **Strong** | SVD spatial divergence, centrists moved left, right moved further right | Confirmed |
| **Strong** | Migration domain: centrist M=5 support went from 0.0 to 0.185, acceptance expansion | Confirmed on n=379 migration motions | | **Strong** | Migration domain: centrist M=5 support went from 0.0 to 0.185, acceptance expansion | Confirmed on n=379 migration motions |
@ -267,7 +267,7 @@ Centrist support for right-wing motions surged from 25% to 51%, while centrist s
| **Strong** | Climate/stikstof: system abolition (CS=0.0) replaced by operational proposals (CS up to 1.0) | Confirmed | | **Strong** | Climate/stikstof: system abolition (CS=0.0) replaced by operational proposals (CS up to 1.0) | Confirmed |
| **Strong** | Temporal trajectory: shift was immediate electoral jump (+0.180), peaked 2024-Q4 (0.648), reverting | Confirmed on 33 quarters | | **Strong** | Temporal trajectory: shift was immediate electoral jump (+0.180), peaked 2024-Q4 (0.648), reverting | Confirmed on 33 quarters |
| **Strong** | Causal mechanism: electorally driven (before cabinet, after PVV election); coalition/learning/contagion less consistent with timing | Confirmed | | **Strong** | Causal mechanism: electorally driven (before cabinet, after PVV election); coalition/learning/contagion less consistent with timing | Confirmed |
| **Strong** | 2D extremity: both dimensions declined post-2024 (stijl −0.131, materieel −0.336); holistic moderation confirmed | Confirmed on n=3,030 | | **Strong** | 2D extremity: both dimensions declined post-2024 (stijl −0.131, materieel −0.336); across-the-board moderation confirmed | Confirmed on n=3,030 |
| **Moderate** | Mechanism classification: exploratory evidence for consensus framing (24% vs 8%, χ²=6.0, p=0.014) | Exploratory, κ=0.41 inter-rater reliability, n=200 classified motions | | **Moderate** | Mechanism classification: exploratory evidence for consensus framing (24% vs 8%, χ²=6.0, p=0.014) | Exploratory, κ=0.41 inter-rater reliability, n=200 classified motions |
| **Strong** | Left-wing response: minimal change (−1.1 pp vs centrist +20.6 pp), 18.3x asymmetry | Confirmed | | **Strong** | Left-wing response: minimal change (−1.1 pp vs centrist +20.6 pp), 18.3x asymmetry | Confirmed |
| **Moderate** | Anti-institutional pivot: abolition (nexit, constitution) disappeared; contestation (judiciary critique) increased | Keyword-based detection, small absolute counts | | **Moderate** | Anti-institutional pivot: abolition (nexit, constitution) disappeared; contestation (judiciary critique) increased | Keyword-based detection, small absolute counts |

@ -1,202 +1,707 @@
<!doctype html> <!DOCTYPE html>
<html lang="en"> <html xmlns="http://www.w3.org/1999/xhtml" lang="en" xml:lang="en"><head>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mapping Dutch Democracy: Building a Political Compass</title>
<style>
body{font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:900px;margin:40px auto;line-height:1.7;color:#c9d1d9;background:#0d1117;padding:0 20px}
pre{background:#161b22;padding:14px;border-radius:8px;overflow:auto;border:1px solid #30363d}
code{font-family:'JetBrains Mono','Fira Code',monospace;background:#1c2128;padding:2px 6px;border-radius:4px;font-size:0.9em}
pre code{background:none;padding:0;font-size:0.85em;line-height:1.5}
h1,h2,h3{color:#58a6ff;font-weight:600}
h1{font-size:1.8em;margin-top:1.2em}
h2{font-size:1.4em;margin-top:1.8em}
h3{font-size:1.15em;margin-top:1.4em}
a{color:#58a6ff;text-decoration:none}
a:hover{text-decoration:underline}
ul{margin-left:1.2rem}
strong{color:#e6edf3}
.callout{background:#161b22;border-left:4px solid #58a6ff;padding:12px 16px;margin:20px 0;border-radius:0 8px 8px 0}
.finding{background:#0d1f0d;border-left:4px solid #3fb950;padding:12px 16px;margin:20px 0;border-radius:0 8px 8px 0}
table{border-collapse:collapse;width:100%;margin:16px 0}
th,td{border:1px solid #30363d;padding:8px 12px;text-align:left}
th{background:#161b22;color:#58a6ff}
td{color:#c9d1d9}
em{color:#8b949e}
</style>
</head>
<body>
<h1>Mapping Dutch Democracy: Building a Political Compass from 28,000 Parliamentary Votes</h1>
<p><em>What if you could take every motion voted on in the Dutch Parliament over the past decade and automatically plot parties and MPs on a political map — with zero manual labeling?</em></p>
<p>That's exactly what this project does. Here's how I built it, what I had to solve along the way, and what it revealed about Dutch political dynamics.</p>
<h2>The Starting Point: Open Data, Hidden Structure</h2> <meta charset="utf-8">
<p>The Dutch Parliament publishes every vote — every <em>motie</em>, every <em>amendement</em>, every <em>besluit</em> — in an open OData API. We're talking over <strong>28,000 distinct motions</strong> spanning 2016 to 2026, each with a record of how every individual MP voted: <em>voor</em> (for), <em>tegen</em> (against), <em>onthouden</em> (abstained), or <em>afwezig</em> (absent). That's over 500,000 individual vote records.</p> <meta name="generator" content="quarto-1.9.38">
<div class="callout"> <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes">
<strong>A note on the numbers:</strong> The 28,000 figure counts distinct parliamentary decisions (motions, amendments, legislative proposals). The 500,000+ figure counts individual MP votes — each motion generates roughly 18 vote records (one per voting MP or party bloc). At ~3,000–4,000 motions per year and 70–80 parliamentary sitting days, that's roughly 50 votes per sitting day. The Dutch Second Chamber is prolific.
</div>
<p>This is an extraordinary dataset. But in raw form it's just a table of votes. The interesting question is: can we extract <em>structure</em> — left vs. right, progressive vs. conservative, governing vs. opposition — purely from the pattern of who votes with whom?</p> <meta name="author" content="Stemwijzer Analysis">
<p>The answer is yes, and the method is surprisingly elegant.</p> <meta name="dcterms.date" content="2026-06-16">
<h2>Step 1: Turning Votes into Geometry</h2> <title>Mapping Dutch Democracy: Building a Political Compass from 29,000+ Parliamentary Votes</title>
<p>Each motion is a snapshot of political alignment. For each motion, we know which MPs voted together and which voted apart. If every PvdA and GroenLinks MP votes the same way almost every time, that tells us something. If PVV and CDA MPs diverge consistently, that tells us something too.</p> <style>
<p>I represent this with <strong>Singular Value Decomposition (SVD)</strong> on the MP × motion matrix:</p> /* Default styles provided by pandoc.
<ul> ** See https://pandoc.org/MANUAL.html#variables-for-html for config info.
<li>Rows: individual MPs (and party actors for collective votes)</li> */
<li>Columns: motions</li> code{white-space: pre-wrap;}
<li>Values: +1 (voor), −1 (tegen), 0 (absent/abstain)</li> span.smallcaps{font-variant: small-caps;}
</ul> div.columns{display: flex; gap: min(4vw, 1.5em);}
<p>SVD finds the dominant axes of variation — the directions along which the chamber disagrees most. The first component almost always corresponds to a left-right axis. The second typically captures something like progressive-traditionalist or libertarian-authoritarian. The key point: <strong>the axes emerge from the math, not from any labeling on my part.</strong></p> div.column{flex: auto; overflow-x: auto;}
div.hanging-indent{margin-left: 1.5em; text-indent: -1.5em;}
<h3>Making Windows Comparable: Procrustes Alignment</h3> ul.task-list{list-style: none;}
<p>Running SVD independently per time window creates a subtle problem: SVD axes are <strong>arbitrarily oriented</strong>. The "left-right" axis from 2020-Q3 and the "left-right" axis from 2021-Q1 might point in completely different directions — even if the underlying politics barely changed. You can't just stack the coordinates and call it a trajectory.</p> ul.task-list li input[type="checkbox"] {
<p>The fix is <strong>Procrustes alignment</strong>: given two sets of party/MP positions across consecutive windows, find the rotation matrix R that best maps one onto the other (minimizing the Frobenius norm of the difference), using MPs who appear in both windows as anchors:</p> width: 0.8em;
<pre><code>R = argmin_R ||A − B @ R||_F, subject to R'R = I</code></pre> margin: 0 0.8em 0.2em -1em; /* quarto-specific, see https://github.com/quarto-dev/quarto-cli/issues/4556 */
<p>This is solved cleanly via SVD of the cross-covariance matrix (a nice piece of mathematical symmetry — SVD to build the space, SVD to align it). The result: a continuous track for every party from 2019 to 2026, where position changes reflect genuine political movement rather than axis flips.</p> vertical-align: middle;
}
/* CSS for syntax highlighting */
html { -webkit-text-size-adjust: 100%; }
pre > code.sourceCode { white-space: pre; position: relative; }
pre > code.sourceCode > span { display: inline-block; line-height: 1.25; }
pre > code.sourceCode > span:empty { height: 1.2em; }
.sourceCode { overflow: visible; }
code.sourceCode > span { color: inherit; text-decoration: inherit; }
div.sourceCode { margin: 1em 0; }
pre.sourceCode { margin: 0; }
@media screen {
div.sourceCode { overflow: auto; }
}
@media print {
pre > code.sourceCode { white-space: pre-wrap; }
pre > code.sourceCode > span { text-indent: -5em; padding-left: 5em; }
}
pre.numberSource code
{ counter-reset: source-line 0; }
pre.numberSource code > span
{ position: relative; left: -4em; counter-increment: source-line; }
pre.numberSource code > span > a:first-child::before
{ content: counter(source-line);
position: relative; left: -1em; text-align: right; vertical-align: baseline;
border: none; display: inline-block;
-webkit-touch-callout: none; -webkit-user-select: none;
-khtml-user-select: none; -moz-user-select: none;
-ms-user-select: none; user-select: none;
padding: 0 4px; width: 4em;
}
pre.numberSource { margin-left: 3em; padding-left: 4px; }
div.sourceCode
{ }
@media screen {
pre > code.sourceCode > span > a:first-child::before { text-decoration: underline; }
}
</style>
<h2>Step 2: Finding Similar Motions</h2>
<p>Once we have SVD vectors for every motion in a window, we can find the most politically similar motions. Two motions are close if they produce a similar split in the chamber — same parties voting the same way.</p>
<p>The similarity computation is pure NumPy: load all SVD vectors for a window, L2-normalize, compute cosine similarity via a single matrix multiply, then extract top-k neighbors. For a 4,000-motion quarter, that's a 4000×4000 matrix — fast enough without batching.</p>
<h2>The Numbers: What We're Working With</h2> <script src="blog-post-political-compass_files/libs/clipboard/clipboard.min.js"></script>
<script src="blog-post-political-compass_files/libs/quarto-html/quarto.js" type="module"></script>
<script src="blog-post-political-compass_files/libs/quarto-html/tabsets/tabsets.js" type="module"></script>
<script src="blog-post-political-compass_files/libs/quarto-html/popper.min.js"></script>
<script src="blog-post-political-compass_files/libs/quarto-html/tippy.umd.min.js"></script>
<script src="blog-post-political-compass_files/libs/quarto-html/anchor.min.js"></script>
<link href="blog-post-political-compass_files/libs/quarto-html/tippy.css" rel="stylesheet">
<link href="blog-post-political-compass_files/libs/quarto-html/quarto-syntax-highlighting-15634bcf2e68342d4ad2dfa704d543f6.css" rel="stylesheet" id="quarto-text-highlighting-styles">
<script src="blog-post-political-compass_files/libs/bootstrap/bootstrap.min.js"></script>
<link href="blog-post-political-compass_files/libs/bootstrap/bootstrap-icons.css" rel="stylesheet">
<link href="blog-post-political-compass_files/libs/bootstrap/bootstrap-ead859a0cde6e94fc21d93203ba7f4bc.min.css" rel="stylesheet" append-hash="true" id="quarto-bootstrap" data-mode="light">
<table>
<thead><tr><th>Year</th><th>Motions</th><th>Breakdown</th></tr></thead>
<tbody>
<tr><td>2016</td><td>162</td><td>Mostly legislative proposals (data incomplete)</td></tr>
<tr><td>2017</td><td>126</td><td>Mostly legislative proposals (data incomplete)</td></tr>
<tr><td>2018</td><td>124</td><td>Mostly legislative proposals (data incomplete)</td></tr>
<tr><td>2019</td><td>3,374</td><td>2,058 moties + 350 amendementen</td></tr>
<tr><td>2020</td><td>4,223</td><td>3,141 moties + 354 amendementen</td></tr>
<tr><td>2021</td><td>4,283</td><td>3,395 moties + 236 amendementen</td></tr>
<tr><td>2022</td><td>4,115</td><td>3,255 moties + 290 amendementen</td></tr>
<tr><td>2023</td><td>3,272</td><td>2,557 moties + 217 amendementen</td></tr>
<tr><td>2024</td><td>3,965</td><td>3,007 moties + 359 amendementen</td></tr>
<tr><td>2025</td><td>3,712</td><td>2,900 moties + 251 amendementen</td></tr>
<tr><td>2026</td><td>948</td><td>849 moties + 21 amendementen (partial year)</td></tr>
</tbody>
</table>
<p>Early years (2016–2018) are incomplete — the API data for this period is sparse and mostly contains legislative proposals rather than parliamentary motions. From 2019 onwards, the data is comprehensive, running quarterly for 41 time windows in total.</p> </head>
<div class="callout"> <body class="fullcontent quarto-light">
<strong>The 2022 spike is striking.</strong> Over 4,000 motions in a single year — this was when the Rutte IV coalition governed amid intense debates on energy prices, housing, the war in Ukraine, and the ongoing nitrogen crisis. 2023 culminated in the November election that brought PVV to its historic first-place finish with 37 seats.
</div>
<h2>Finding 1: The Merger That Was Already Written in the Votes</h2> <div id="quarto-content" class="page-columns page-rows-contents page-layout-article">
<div class="finding"> <main class="content" id="quarto-document-content">
<strong>The GroenLinks–PvdA merger wasn't a surprise to the data.</strong> In the raw SVD vectors, they appear as separate parties from 2019 through 2023 — but their coordinates were already converging. By late 2022, the distance between them was smaller than the internal variation within most other parties. By 2023-Q3 — the last quarter before the formal merger — GroenLinks and PvdA agreed on <strong>99.8%</strong> of recorded votes.
<img src="../docs/research/party_agreement_2023Q3.png" alt="Party agreement matrix — 2023-Q3" style="width:100%;max-width:700px;border-radius:8px;margin:12px 0;display:block"> <header id="title-block-header" class="quarto-title-block default">
<div class="quarto-title">
<h1 class="title">Mapping Dutch Democracy: Building a Political Compass from 29,000+ Parliamentary Votes</h1>
</div> </div>
<p>The raw data preserves the distinction carefully. From 2019 through mid-2023, the <code>svd_vectors</code> table lists <strong>GroenLinks</strong> and <strong>PvdA</strong> as separate entries per window. From late 2023 onwards — when the merger formally took effect in parliament — a single <strong>GroenLinks-PvdA</strong> entity appears. The pipeline tracks this faithfully: you can literally watch two separate points on the political compass drift together and then merge into one.</p>
<p>What's striking is <em>how early</em> the convergence is visible. By 2021 — two full years before the merger announcement — GroenLinks and PvdA coordinates in the SVD space are nearly overlapping. At the individual MP level, there was occasional divergence on defense and security votes (GroenLinks MPs pulling slightly away from the PvdA centroid), but at the party level they were practically indistinguishable.</p>
<p>This created an interesting pipeline challenge: the party normalization step has a mapping that folds both names into <code>GroenLinks-PvdA</code> across the <em>entire</em> dataset. For the post-merger period that's correct; for the pre-merger period it's a simplification that hides the convergence story. The raw vectors still capture it — you just have to know to look.</p> <div class="quarto-title-meta">
<p>After the formal merger, GroenLinks-PvdA became one of the most <strong>cohesive</strong> parties in parliament. Their internal voting discipline rivals SGP and ChristenUnie — near-perfect blocs. VVD, by contrast, shows the most internal variation, which tracks with what you'd expect from a large centrist party managing conflicting wings.</p> <div>
<div class="quarto-title-meta-heading">Author</div>
<div class="quarto-title-meta-contents">
<p>Stemwijzer Analysis </p>
</div>
</div>
<div>
<div class="quarto-title-meta-heading">Published</div>
<div class="quarto-title-meta-contents">
<p class="date">June 16, 2026</p>
</div>
</div>
</div>
<h2>Finding 2: When Left and Right Unite Against the Center</h2>
<div class="finding"> </header>
<strong>The most surprising pattern in the data isn't left vs. right — it's left <em>and</em> right vs. the governing coalition.</strong>
</div>
<p>During the Rutte IV cabinet (2022–2023), a recurring pattern emerged: PVV, FvD, and JA21 (right-wing) would vote with SP, GroenLinks-PvdA, PvdD, DENK, and Volt (left-wing) <strong>against</strong> the governing parties VVD, D66, CDA, and ChristenUnie. This isn't a one-off — it happened on dozens of motions.</p>
<p>The topics tell the story:</p>
<ul>
<li><strong>Disability care bureaucracy</strong> — motions to reduce administrative burden in disability care. The populist right and the progressive left both opposed the coalition's market-oriented approach.</li>
<li><strong>Respite care for intensive caregivers</strong> — same coalition of radical left and radical right, opposing centrist fiscal restraint.</li>
<li><strong>Anti-fraud budget retention</strong> — the coalition wanted to maintain the anti-fraud apparatus (think: the toeslagenaffaire aftermath); both flanks pushed back.</li>
<li><strong>Education funding</strong> — motions to increase fundamental education budgets. VVD and D66 voted against; PVV and SP voted together.</li>
<li><strong>Regional infrastructure</strong> — train stations, Eindhoven connectivity, regional investment. Left+right voted for; coalition voted against.</li>
</ul>
<p>This is the classic "horseshoe" pattern in political science — the extremes converging against the center — but it's remarkable to see it so clearly in the voting geometry. It's not ideological agreement between left and right; it's a shared opposition to the governing consensus.</p>
<h2>Finding 3: BBB's Geometric Arrival</h2>
<p>When BBB (BoerBurgerBeweging) entered parliament after the 2023 provincial elections, their SVD position placed them between PVV and CDA — consistent with their policy profile: agrarian-nationalist populism with Catholic-provincial roots. New parties don't get to pick their geometric location; the voting record places them. That BBB landed exactly where you'd expect is a good validity check.</p>
<p>What the geometry also shows: BBB started close to PVV on the nationalist axis, but drifted toward the CDA cluster over their first year in parliament — visible as a curved trajectory rather than a fixed point.</p>
<h2>Finding 4: The Closest Votes in a Decade</h2>
<p>The controversy score (<code>1 − winning_margin</code>) reveals the knife-edge votes. In the current fragmented parliament, the tightest split is a perfect <strong>8–8 party-line tie</strong> — decided by the chamber chair's casting vote. These happened on:</p>
<p><em>What if you could take every motion voted on in the Dutch Parliament over the past decade and automatically plot parties and MPs on a political map — with zero manual labeling?</em></p>
<p>That’s exactly what this project does. Here’s how I built it, what I had to solve along the way, and what it revealed about Dutch political dynamics.</p>
<hr>
<section id="the-starting-point-open-data-hidden-structure" class="level2">
<h2 class="anchored" data-anchor-id="the-starting-point-open-data-hidden-structure">The Starting Point: Open Data, Hidden Structure</h2>
<p>The Dutch Parliament publishes every vote — every <em>motie</em>, every <em>amendement</em>, every <em>besluit</em> — in an open OData API. We’re talking over <strong>29,500 motions</strong> spanning 2016 to 2026, with a record of how every individual MP voted: <em>voor</em> (for), <em>tegen</em> (against), <em>onthouden</em> (abstained), or <em>afwezig</em> (absent). That’s 531,000 individual vote records.</p>
<p>This is an extraordinary dataset. But in raw form it’s just a table of votes. The interesting question is: can we extract <em>structure</em> — left vs.&nbsp;right, progressive vs.&nbsp;conservative, governing vs.&nbsp;opposition — purely from the pattern of who votes with whom?</p>
<p>The answer is yes, and the method is surprisingly elegant.</p>
<hr>
</section>
<section id="step-1-turning-votes-into-geometry" class="level2">
<h2 class="anchored" data-anchor-id="step-1-turning-votes-into-geometry">Step 1: Turning Votes into Geometry</h2>
<p>Each motion is a snapshot of political alignment. For each motion, we know which MPs voted together and which voted apart. If every PvdA and GroenLinks MP votes the same way almost every time, that tells us something. If PVV and CDA MPs diverge consistently, that tells us something too.</p>
<p>I represent this with <strong>Singular Value Decomposition (SVD)</strong> on the MP × motion matrix:</p>
<ul> <ul>
<li><strong>Family reunification for AMV status holders</strong> (Boomsma motion, 2025) — immigration policy at its most contested</li> <li>Rows: individual MPs (and party actors for collective votes)</li>
<li><strong>Nuclear weapons and NATO</strong> (Dobbe motion, 2025) — whether to push for nuclear disarmament within the alliance</li> <li>Columns: motions</li>
<li><strong>Long COVID research funding</strong> (Kostic motion, 2025) — healthcare commitments that split parties along unexpected lines</li> <li>Values: +1 (voor), -1 (tegen), 0 (absent/abstain)</li>
<li><strong>Cormorant population management</strong> (Kostic motion, 2025) — agricultural vs. ecological interests in a literal bird-counting exercise</li>
</ul> </ul>
<p>SVD finds the dominant axes of variation — the directions along which the chamber disagrees most. The first component almost always corresponds to a left-right axis. The second typically captures something like progressive-traditionalist or libertarian-authoritarian. The key point: <strong>the axes emerge from the math, not from any labeling on my part.</strong></p>
<p>The narrowest non-tie votes are razor-thin too: Wilders' asylum emergency stop motion lost by the slimmest margin (5 parties for, 16 tied — effectively blocked), while Marijnissen's motion against private equity in GP practices nearly flipped the other way (16 for, 5 tied). On a different day, a different MP showing up, Dutch immigration and healthcare policy could have shifted.</p> <p>I request 50 SVD dimensions per window — but the actual dimensionality is constrained by <code>min(n_MPs, n_motions) - 1</code>. Sparse windows (early years, partial quarters) produce fewer meaningful dimensions. The pipeline handles this gracefully, storing whatever <code>k_used</code> is for each window so downstream fusion always works with the actual vector length.</p>
<section id="making-windows-comparable-procrustes-alignment" class="level3">
<p>More broadly, over <strong>15,000 motions</strong> had winning margins below 55% — these are the genuinely contested decisions, not the rubber stamps. At the other extreme, about 3,700 motions passed with 95%+ support: the uncontroversial consensus items that rarely make headlines.</p> <h3 class="anchored" data-anchor-id="making-windows-comparable-procrustes-alignment">Making Windows Comparable: Procrustes Alignment</h3>
<p>Running SVD independently per window creates a subtle problem: SVD axes are <strong>arbitrarily oriented</strong>. The “left-right” axis from 2020-Q3 and the “left-right” axis from 2021-Q1 might point in completely different directions — even if the underlying politics barely changed. You can’t just stack the coordinates and call it a trajectory.</p>
<h2>The Pipeline Architecture</h2> <p>The fix is <strong>Procrustes alignment</strong>: given two sets of party/MP positions across consecutive windows, find the rotation matrix R that best maps one onto the other (minimizing the Frobenius norm of the difference), using MPs who appear in both windows as anchors:</p>
<pre><code>R = argmin_R ||A - B @ R||_F, subject to R'R = I</code></pre>
<p>This is solved cleanly via SVD of the cross-covariance matrix (a nice piece of mathematical symmetry — SVD to build the space, SVD to align it). The result: a continuous track for every party from 2019 to 2026, where position changes reflect genuine political movement rather than axis flips.</p>
<p>High Procrustes disparity between consecutive windows — where alignment is poor even with the best rotation — is itself a signal: it suggests a structural political shift, not just individual drift.</p>
<hr>
</section>
</section>
<section id="step-2-what-each-motion-is-actually-about" class="level2">
<h2 class="anchored" data-anchor-id="step-2-what-each-motion-is-actually-about">Step 2: What Each Motion Is Actually About</h2>
<p>Voting patterns tell us <em>who</em> agrees, but not <em>why</em>. For that, I add <strong>text embeddings</strong> — dense vector representations of each motion’s content using a language model.</p>
<p>I use <strong><code>qwen/qwen3-embedding-4b</code></strong> via OpenRouter — a 4-billion parameter multilingual model that produces 2560-dimensional vectors with strong Dutch-language support. For each motion, I embed the richest text available: full parliamentary body text when we have it (94% of the 29,570 motions after an enrichment pass against the Tweede Kamer API), falling back to the summary description or title otherwise.</p>
<p>This lets us do something powerful: find motions that are genuinely similar in <em>topic</em>, not just in voting pattern. Two motions about nitrogen policy from 2020 and 2023 might have very different vote splits (different coalitions, different political moment) but near-identical text embeddings. That’s a meaningful connection.</p>
<hr>
</section>
<section id="step-3-fused-embeddings-the-best-of-both-worlds" class="level2">
<h2 class="anchored" data-anchor-id="step-3-fused-embeddings-the-best-of-both-worlds">Step 3: Fused Embeddings — The Best of Both Worlds</h2>
<p>SVD gives the political-structural signal: <em>how does this motion split the chamber?</em> Text embeddings give the semantic signal: <em>what is this motion about?</em></p>
<p>I concatenate both into a <strong>fused vector</strong> per motion per window:</p>
<pre><code>fused = [svd_dims (typically 50)] + [text_dims (2560)] = typically 2610 dimensions</code></pre>
<p>The actual dimension varies slightly because SVD dimensionality adapts to window density — the code stores <code>svd_dims</code> and <code>text_dims</code> per row so nothing downstream has to assume a fixed size.</p>
<p>This fused representation powers the similarity search. Two motions are “close” only if they’re about a similar <em>topic</em> <strong>and</strong> they produce a similar <em>political split</em>. This filters out spurious matches — two motions might both be controversial (close 50/50 votes) but on completely unrelated things, and the text component separates them.</p>
<hr>
</section>
<section id="the-numbers-what-were-working-with" class="level2">
<h2 class="anchored" data-anchor-id="the-numbers-what-were-working-with">The Numbers: What We’re Working With</h2>
<p>After the full pipeline run:</p>
<table class="caption-top table">
<thead>
<tr class="header">
<th>Year</th>
<th>Motions</th>
</tr>
</thead>
<tbody>
<tr class="odd">
<td>2016</td>
<td>162</td>
</tr>
<tr class="even">
<td>2017</td>
<td>126</td>
</tr>
<tr class="odd">
<td>2018</td>
<td>124</td>
</tr>
<tr class="even">
<td>2019</td>
<td>3,374</td>
</tr>
<tr class="odd">
<td>2020</td>
<td>4,223</td>
</tr>
<tr class="even">
<td>2021</td>
<td>4,283</td>
</tr>
<tr class="odd">
<td>2022</td>
<td>4,115</td>
</tr>
<tr class="even">
<td>2023</td>
<td>3,272</td>
</tr>
<tr class="odd">
<td>2024</td>
<td>3,965</td>
</tr>
<tr class="even">
<td>2025</td>
<td>3,712</td>
</tr>
<tr class="odd">
<td>2026</td>
<td>2,214</td>
</tr>
<tr class="even">
<td><strong>Total</strong></td>
<td><strong>29,570</strong></td>
</tr>
</tbody>
</table>
<p>The 2022 spike is striking — over 4,000 motions in a single year. This was the year the Rutte IV coalition took office amid intense debates on energy prices, housing, the war in Ukraine, and the ongoing nitrogen crisis. 2023 is similarly dense at 3,272 motions, culminating in the November election that brought PVV to its historic first-place finish.</p>
<p>Early years (2016–2018) use annual windows because the data is too sparse for meaningful quarterly SVD. From 2019 onwards, everything runs quarterly, giving us 41 windows in total.</p>
<p>The similarity cache holds <strong>409,938 precomputed pairs</strong> — top 10 neighbors per motion per window — making lookup instant at query time.</p>
<hr>
</section>
<section id="interesting-findings" class="level2">
<h2 class="anchored" data-anchor-id="interesting-findings">Interesting Findings</h2>
<section id="the-20222023-polarization-surge" class="level3">
<h3 class="anchored" data-anchor-id="the-20222023-polarization-surge">The 2022–2023 Polarization Surge</h3>
<p>2022 and 2023 together account for more than a quarter of all motions in the dataset. In the SVD positions for 2022, the distance between the governing coalition (VVD, D66, CDA, CU) and the opposition (PVV, SP, FvD) is near its maximum. The nitrogen crisis and energy policy debates forced unusually sharp coalition discipline — which shows up geometrically as well-separated clusters.</p>
<p>2023 continued the intensity, and the Procrustes-aligned trajectory shows the party positions in 2023-Q4 and 2024-Q1 shifting noticeably as the new coalition began to form.</p>
</section>
<section id="bbbs-geometric-arrival" class="level3">
<h3 class="anchored" data-anchor-id="bbbs-geometric-arrival">BBB’s Geometric Arrival</h3>
<p>When BBB (BoerBurgerBeweging) entered parliament in 2023 with a historic 16 seats, their SVD position placed them between PVV and CDA — exactly matching their policy profile: agrarian-nationalist populism with Catholic-provincial roots. The model found this without being told. That’s a good sanity check that the geometry is capturing something real.</p>
</section>
<section id="the-strange-case-of-verworpen." class="level3">
<h3 class="anchored" data-anchor-id="the-strange-case-of-verworpen.">The Strange Case of “Verworpen.”</h3>
<p>Motions rejected without debate are recorded with the title “Verworpen.” (Rejected.). There are hundreds of these. Because they share a 9-character title, their text embeddings are <strong>identical</strong> — cosine similarity 1.0 to every other “Verworpen.” in the cache. Technically correct; semantically meaningless. The UI layer filters these out.</p>
<p>It’s a reminder that <strong>data quality surprises emerge at scale</strong>. I found three or four similar pathologies (motions withdrawn mid-session, duplicate API records) that required explicit handling.</p>
</section>
<section id="party-cohesion-as-a-signal" class="level3">
<h3 class="anchored" data-anchor-id="party-cohesion-as-a-signal">Party Cohesion as a Signal</h3>
<p>Party cohesion — how often all MPs of a party vote identically — varies enormously. SGP and CU are near-perfect blocs. PvdA/GroenLinks (post-2023 merger) is similarly tight. VVD shows the most internal variation, which tracks with what you’d expect from a governing party managing coalition discipline across conflicting wings.</p>
<p>In earlier years (2019–2020), before the GroenLinks-PvdA merger, GroenLinks occasionally splits on security and defense policy — visible in the SVD as individual MP positions diverging from the party centroid.</p>
<hr>
</section>
</section>
<section id="the-pipeline-architecture" class="level2">
<h2 class="anchored" data-anchor-id="the-pipeline-architecture">The Pipeline Architecture</h2>
<p>Single DuckDB database, modular Python pipeline, no cloud infrastructure:</p> <p>Single DuckDB database, modular Python pipeline, no cloud infrastructure:</p>
<pre><code>API (Tweede Kamer OData) <pre><code>API (Tweede Kamer OData)
→ download_past_year.py → motions table (28,304 rows) → download_past_year.py
→ motions table (29,570 rows)
motions motions
→ extract_mp_votes.py → mp_votes table (508,765 rows) → extract_mp_votes.py → mp_votes table (531,869 rows)
→ sync_motion_content.py → body_text enrichment (~94% coverage) → sync_motion_content.py → body_text enrichment (~94%)
→ svd_pipeline.py → svd_vectors table (73,165 rows, 41 windows) → text_pipeline.py → embeddings table (28,680 rows, qwen3-embedding-4b via OpenRouter)
→ svd_pipeline.py → svd_vectors table (73,172 rows, 41 windows)
svd_vectors
→ similarity/compute.py → similarity_cache (top-10 per window)</code></pre> svd_vectors + embeddings
<p>The similarity computation is pure NumPy: load all SVD vectors for a window, pad to uniform length, L2-normalize, compute the full cosine similarity matrix via a single matrix multiply, then extract top-k neighbors. For a 4,000-motion quarter, that's a 4000×4000 matrix operation — fast enough that batching isn't needed.</p> → fusion.py → fused_embeddings table (41,422 rows)
<p>The database sits at ~18 GB on disk — the full parliamentary text for 26,000+ motions accounts for most of that.</p>
fused_embeddings
<h2>What the Axes Actually Mean</h2> → similarity/compute.py → similarity_cache table (409,938 rows, top-10 per window)</code></pre>
<p>The similarity computation is pure NumPy: load all fused vectors for a window, pad to uniform length, L2-normalize, compute the full <code>N×N</code> cosine similarity matrix via a single matrix multiply (<code>normalized @ normalized.T</code>), then extract top-k neighbors per row with <code>np.argpartition</code>. For a 4,000-motion quarter, that’s a 4000×4000 matrix operation — fast enough that it’s not worth batching.</p>
<p>One of the trickiest problems was labeling the SVD axes. The first component reliably captures left-right economics. But components 3 through 10? The mathematical procedure is sound — SVD finds the directions of maximum variance — but the <em>meaning</em> of each axis has to be derived from the actual motions that load heavily on it.</p> <p>The database sits at 18 GB on disk — up from ~3 GB before body text enrichment. The full parliamentary text for 28,000+ motions accounts for most of that growth.</p>
<hr>
<p>I solved this by extracting the top 50 motions per component (by absolute loading score), then analyzing their content. Some clear patterns emerged:</p> </section>
<section id="what-i-built-on-top" class="level2">
<ul> <h2 class="anchored" data-anchor-id="what-i-built-on-top">What I Built On Top</h2>
<li><strong>Component 1</strong>: Fiscal-economic policy vs. social welfare and international rights — the classic left-right split.</li> <p>The pipeline above is the foundation. Here’s what it now powers:</p>
<li><strong>Component 2</strong>: Nationalist vs. multilateralist orientation — PVV/FvD on one side, Volt/GroenLinks-PvdA on the other.</li> <p><strong>Overton Window analysis</strong>: Using the SVD compass and vote records, I tested whether the Dutch Overton window shifted after PVV’s November 2023 election victory. The answer: it widened, but through right-wing moderation rather than centrist conversion. Centrist support for right-wing motions rose from 25% to 51%, while centrists actually moved <em>left</em> on the SVD compass. The full analysis covers 3,030 classified right-wing motions, 2D extremity scoring, quarterly trajectories, and mechanism classification. <a href="../reports/overton_window/overton_report.html">Read the full report →</a></p>
<li><strong>Component 3</strong>: Welfare state vs. defense spending — flip of the usual axis (with SP/PvdD on the pro-welfare side, VVD/SGP on the pro-defense side).</li> <p><strong>2D extremity scoring</strong>: Every motion in the database has been scored by an LLM on two independent dimensions: stylistic extremity (rhetorical hostility) and material impact (policy consequence). They’re only moderately correlated (r = 0.43), which matters: right-wing motions post-2024 became milder on <em>both</em> dimensions, not just in tone.</p>
</ul> <p><strong>Streamlit Explorer</strong>: An interactive dashboard where you can browse the SVD compass, trace party trajectories over time, explore centrist support trends, and browse individual motions with their extremity scores and similarity matches. The same data and methods that drive the analysis reports power the live exploration interface.</p>
<hr>
<div class="callout"> </section>
<strong>How much do the first two axes actually capture?</strong> In a single-window SVD (current parliament), PC1 explains ~29% of the variance and PC2 explains ~11.5% — together accounting for <strong>~41%</strong> of all voting variation. PC3 adds another 8.6%, but from there it drops off sharply: PC4 is under 9%, and components 5–8 each contribute 3–6%. The classic "scree plot" elbow is clear: the first two dimensions carry the signal, the rest is real but diminishing. When looking across <em>all</em> time windows with Procrustes alignment, the picture flattens considerably — PC1 and PC2 each explain ~14.6% and ~13.1% respectively — because aligning 41 different windows distributes variance more evenly. The multi-window perspective is more conservative, but the message is the same: Dutch politics is largely two-dimensional. <section id="reproducibility" class="level2">
<h2 class="anchored" data-anchor-id="reproducibility">Reproducibility</h2>
<img src="../docs/research/scree_multiwindow.png" alt="Scree plot — multi-window Procrustes-aligned SVD" style="width:100%;max-width:600px;border-radius:8px;margin:12px 0;display:block"> <div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb4"><pre class="sourceCode bash code-with-copy"><code class="sourceCode bash"><span id="cb4-1"><a href="#cb4-1" aria-hidden="true" tabindex="-1"></a><span class="co"># Download historical data</span></span>
<em style="font-size:0.85em">Scree plot across 41 aligned quarterly windows. PC1 = 14.6%, PC2 = 13.1%.</em> <span id="cb4-2"><a href="#cb4-2" aria-hidden="true" tabindex="-1"></a><span class="ex">python</span> scripts/download_past_year.py <span class="at">--start-date</span> 2016-01-01 <span class="at">--end-date</span> 2026-01-01</span>
</div> <span id="cb4-3"><a href="#cb4-3" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb4-4"><a href="#cb4-4" aria-hidden="true" tabindex="-1"></a><span class="co"># Run full pipeline (SVD, text embeddings, fusion, similarity cache)</span></span>
<h2>What's Next</h2> <span id="cb4-5"><a href="#cb4-5" aria-hidden="true" tabindex="-1"></a><span class="ex">python</span> <span class="at">-m</span> pipeline.run_pipeline <span class="at">--db-path</span> data/motions.db <span class="dt">\</span></span>
<span id="cb4-6"><a href="#cb4-6" aria-hidden="true" tabindex="-1"></a> <span class="at">--start-date</span> 2016-01-01 <span class="at">--end-date</span> 2026-01-01 <span class="dt">\</span></span>
<p><strong>Motion explorer</strong>: Given a motion, retrieve the 10 most politically similar ones from across the decade. Trace how a policy debate evolved — who championed it, how the coalitions shifted.</p> <span id="cb4-7"><a href="#cb4-7" aria-hidden="true" tabindex="-1"></a> <span class="at">--window-size</span> quarterly <span class="at">--text-batch-size</span> 200</span>
<span id="cb4-8"><a href="#cb4-8" aria-hidden="true" tabindex="-1"></a></span>
<p><strong>Party trajectory animation</strong>: Procrustes-aligned positions, animated year by year. Watch GroenLinks-PvdA's pre-merger convergence, watch PVV consolidate its flank, watch new parties arrive and find their geometric home.</p> <span id="cb4-9"><a href="#cb4-9" aria-hidden="true" tabindex="-1"></a><span class="co"># Enrich with full motion body text</span></span>
<span id="cb4-10"><a href="#cb4-10" aria-hidden="true" tabindex="-1"></a><span class="ex">python</span> scripts/sync_motion_content.py <span class="at">--db-path</span> data/motions.db</span></code></pre></div><button title="Copy to Clipboard" class="code-copy-button"><i class="bi"></i></button></div>
<p><strong>Cross-party coalition patterns</strong>: Which topics produce unusual coalition configurations — motions where the normal left-right split breaks down and unexpected alliances form.</p> <p>The DB grows to ~18 GB for the full dataset including body text. All computation — SVD, fusion, similarity — runs locally on a single machine.</p>
<p><strong>Cabinet crisis detection</strong>: Track coalition cohesion over time. When do coalition parties start voting against each other? The Procrustes disparity between consecutive windows is itself a signal of structural political shifts.</p>
<h2>Reproducibility</h2>
<pre><code># Download historical data
python scripts/download_past_year.py --start-date 2016-01-01 --end-date 2026-01-01
# Run full pipeline (SVD, similarity cache)
python -m pipeline.run_pipeline --db-path data/motions.db \
--start-date 2016-01-01 --end-date 2026-01-01 \
--window-size quarterly --text-batch-size 200
# Enrich with full motion body text
python scripts/sync_motion_content.py --db-path data/motions.db</code></pre>
<p>All computation — SVD, similarity — runs locally on a single machine. No cloud services, no GPU required.</p>
<p>Democracy is more legible than it looks.</p> <p>Democracy is more legible than it looks.</p>
</body> </section>
</html>
</main>
<!-- /main column -->
<script id="quarto-html-after-body" type="application/javascript">
window.document.addEventListener("DOMContentLoaded", function (event) {
const icon = "";
const anchorJS = new window.AnchorJS();
anchorJS.options = {
placement: 'right',
icon: icon
};
anchorJS.add('.anchored');
const isCodeAnnotation = (el) => {
for (const clz of el.classList) {
if (clz.startsWith('code-annotation-')) {
return true;
}
}
return false;
}
const onCopySuccess = function(e) {
// button target
const button = e.trigger;
// don't keep focus
button.blur();
// flash "checked"
button.classList.add('code-copy-button-checked');
var currentTitle = button.getAttribute("title");
button.setAttribute("title", "Copied!");
let tooltip;
if (window.bootstrap) {
button.setAttribute("data-bs-toggle", "tooltip");
button.setAttribute("data-bs-placement", "left");
button.setAttribute("data-bs-title", "Copied!");
tooltip = new bootstrap.Tooltip(button,
{ trigger: "manual",
customClass: "code-copy-button-tooltip",
offset: [0, -8]});
tooltip.show();
}
setTimeout(function() {
if (tooltip) {
tooltip.hide();
button.removeAttribute("data-bs-title");
button.removeAttribute("data-bs-toggle");
button.removeAttribute("data-bs-placement");
}
button.setAttribute("title", currentTitle);
button.classList.remove('code-copy-button-checked');
}, 1000);
// clear code selection
e.clearSelection();
}
const getTextToCopy = function(trigger) {
const outerScaffold = trigger.parentElement.cloneNode(true);
const codeEl = outerScaffold.querySelector('code');
for (const childEl of codeEl.children) {
if (isCodeAnnotation(childEl)) {
childEl.remove();
}
}
return codeEl.innerText;
}
const clipboard = new window.ClipboardJS('.code-copy-button:not([data-in-quarto-modal])', {
text: getTextToCopy
});
clipboard.on('success', onCopySuccess);
if (window.document.getElementById('quarto-embedded-source-code-modal')) {
const clipboardModal = new window.ClipboardJS('.code-copy-button[data-in-quarto-modal]', {
text: getTextToCopy,
container: window.document.getElementById('quarto-embedded-source-code-modal')
});
clipboardModal.on('success', onCopySuccess);
}
var localhostRegex = new RegExp(/^(?:http|https):\/\/localhost\:?[0-9]*\//);
var mailtoRegex = new RegExp(/^mailto:/);
var filterRegex = new RegExp('/' + window.location.host + '/');
var isInternal = (href) => {
return filterRegex.test(href) || localhostRegex.test(href) || mailtoRegex.test(href);
}
// Inspect non-navigation links and adorn them if external
var links = window.document.querySelectorAll('a[href]:not(.nav-link):not(.navbar-brand):not(.toc-action):not(.sidebar-link):not(.sidebar-item-toggle):not(.pagination-link):not(.no-external):not([aria-hidden]):not(.dropdown-item):not(.quarto-navigation-tool):not(.about-link)');
for (var i=0; i<links.length; i++) {
const link = links[i];
if (!isInternal(link.href)) {
// undo the damage that might have been done by quarto-nav.js in the case of
// links that we want to consider external
if (link.dataset.originalHref !== undefined) {
link.href = link.dataset.originalHref;
}
}
}
function tippyHover(el, contentFn, onTriggerFn, onUntriggerFn) {
const config = {
allowHTML: true,
maxWidth: 500,
delay: 100,
arrow: false,
appendTo: function(el) {
return el.parentElement;
},
interactive: true,
interactiveBorder: 10,
theme: 'quarto',
placement: 'bottom-start',
};
if (contentFn) {
config.content = contentFn;
}
if (onTriggerFn) {
config.onTrigger = onTriggerFn;
}
if (onUntriggerFn) {
config.onUntrigger = onUntriggerFn;
}
window.tippy(el, config);
}
const noterefs = window.document.querySelectorAll('a[role="doc-noteref"]');
for (var i=0; i<noterefs.length; i++) {
const ref = noterefs[i];
tippyHover(ref, function() {
// use id or data attribute instead here
let href = ref.getAttribute('data-footnote-href') || ref.getAttribute('href');
try { href = new URL(href).hash; } catch {}
const id = href.replace(/^#\/?/, "");
const note = window.document.getElementById(id);
if (note) {
return note.innerHTML;
} else {
return "";
}
});
}
const xrefs = window.document.querySelectorAll('a.quarto-xref');
const processXRef = (id, note) => {
// Strip column container classes
const stripColumnClz = (el) => {
el.classList.remove("page-full", "page-columns");
if (el.children) {
for (const child of el.children) {
stripColumnClz(child);
}
}
}
stripColumnClz(note)
if (id === null || id.startsWith('sec-')) {
// Special case sections, only their first couple elements
const container = document.createElement("div");
if (note.children && note.children.length > 2) {
container.appendChild(note.children[0].cloneNode(true));
for (let i = 1; i < note.children.length; i++) {
const child = note.children[i];
if (child.tagName === "P" && child.innerText === "") {
continue;
} else {
container.appendChild(child.cloneNode(true));
break;
}
}
if (window.Quarto?.typesetMath) {
window.Quarto.typesetMath(container);
}
return container.innerHTML
} else {
if (window.Quarto?.typesetMath) {
window.Quarto.typesetMath(note);
}
return note.innerHTML;
}
} else {
// Remove any anchor links if they are present
const anchorLink = note.querySelector('a.anchorjs-link');
if (anchorLink) {
anchorLink.remove();
}
if (window.Quarto?.typesetMath) {
window.Quarto.typesetMath(note);
}
if (note.classList.contains("callout")) {
return note.outerHTML;
} else {
return note.innerHTML;
}
}
}
for (var i=0; i<xrefs.length; i++) {
const xref = xrefs[i];
tippyHover(xref, undefined, function(instance) {
instance.disable();
let url = xref.getAttribute('href');
let hash = undefined;
if (url.startsWith('#')) {
hash = url;
} else {
try { hash = new URL(url).hash; } catch {}
}
if (hash) {
const id = hash.replace(/^#\/?/, "");
const note = window.document.getElementById(id);
if (note !== null) {
try {
const html = processXRef(id, note.cloneNode(true));
instance.setContent(html);
} finally {
instance.enable();
instance.show();
}
} else {
// See if we can fetch this
fetch(url.split('#')[0])
.then(res => res.text())
.then(html => {
const parser = new DOMParser();
const htmlDoc = parser.parseFromString(html, "text/html");
const note = htmlDoc.getElementById(id);
if (note !== null) {
const html = processXRef(id, note);
instance.setContent(html);
}
}).finally(() => {
instance.enable();
instance.show();
});
}
} else {
// See if we can fetch a full url (with no hash to target)
// This is a special case and we should probably do some content thinning / targeting
fetch(url)
.then(res => res.text())
.then(html => {
const parser = new DOMParser();
const htmlDoc = parser.parseFromString(html, "text/html");
const note = htmlDoc.querySelector('main.content');
if (note !== null) {
// This should only happen for chapter cross references
// (since there is no id in the URL)
// remove the first header
if (note.children.length > 0 && note.children[0].tagName === "HEADER") {
note.children[0].remove();
}
const html = processXRef(null, note);
instance.setContent(html);
}
}).finally(() => {
instance.enable();
instance.show();
});
}
}, function(instance) {
});
}
let selectedAnnoteEl;
const selectorForAnnotation = ( cell, annotation) => {
let cellAttr = 'data-code-cell="' + cell + '"';
let lineAttr = 'data-code-annotation="' + annotation + '"';
const selector = 'span[' + cellAttr + '][' + lineAttr + ']';
return selector;
}
const selectCodeLines = (annoteEl) => {
const doc = window.document;
const targetCell = annoteEl.getAttribute("data-target-cell");
const targetAnnotation = annoteEl.getAttribute("data-target-annotation");
const annoteSpan = window.document.querySelector(selectorForAnnotation(targetCell, targetAnnotation));
const lines = annoteSpan.getAttribute("data-code-lines").split(",");
const lineIds = lines.map((line) => {
return targetCell + "-" + line;
})
let top = null;
let height = null;
let parent = null;
if (lineIds.length > 0) {
//compute the position of the single el (top and bottom and make a div)
const el = window.document.getElementById(lineIds[0]);
top = el.offsetTop;
height = el.offsetHeight;
parent = el.parentElement.parentElement;
if (lineIds.length > 1) {
const lastEl = window.document.getElementById(lineIds[lineIds.length - 1]);
const bottom = lastEl.offsetTop + lastEl.offsetHeight;
height = bottom - top;
}
if (top !== null && height !== null && parent !== null) {
// cook up a div (if necessary) and position it
let div = window.document.getElementById("code-annotation-line-highlight");
if (div === null) {
div = window.document.createElement("div");
div.setAttribute("id", "code-annotation-line-highlight");
div.style.position = 'absolute';
parent.appendChild(div);
}
div.style.top = top - 2 + "px";
div.style.height = height + 4 + "px";
div.style.left = 0;
let gutterDiv = window.document.getElementById("code-annotation-line-highlight-gutter");
if (gutterDiv === null) {
gutterDiv = window.document.createElement("div");
gutterDiv.setAttribute("id", "code-annotation-line-highlight-gutter");
gutterDiv.style.position = 'absolute';
const codeCell = window.document.getElementById(targetCell);
const gutter = codeCell.querySelector('.code-annotation-gutter');
gutter.appendChild(gutterDiv);
}
gutterDiv.style.top = top - 2 + "px";
gutterDiv.style.height = height + 4 + "px";
}
selectedAnnoteEl = annoteEl;
}
};
const unselectCodeLines = () => {
const elementsIds = ["code-annotation-line-highlight", "code-annotation-line-highlight-gutter"];
elementsIds.forEach((elId) => {
const div = window.document.getElementById(elId);
if (div) {
div.remove();
}
});
selectedAnnoteEl = undefined;
};
// Handle positioning of the toggle
window.addEventListener(
"resize",
throttle(() => {
elRect = undefined;
if (selectedAnnoteEl) {
selectCodeLines(selectedAnnoteEl);
}
}, 10)
);
function throttle(fn, ms) {
let throttle = false;
let timer;
return (...args) => {
if(!throttle) { // first call gets through
fn.apply(this, args);
throttle = true;
} else { // all the others get throttled
if(timer) clearTimeout(timer); // cancel #2
timer = setTimeout(() => {
fn.apply(this, args);
timer = throttle = false;
}, ms);
}
};
}
// Attach click handler to the DT
const annoteDls = window.document.querySelectorAll('dt[data-target-cell]');
for (const annoteDlNode of annoteDls) {
annoteDlNode.addEventListener('click', (event) => {
const clickedEl = event.target;
if (clickedEl !== selectedAnnoteEl) {
unselectCodeLines();
const activeEl = window.document.querySelector('dt[data-target-cell].code-annotation-active');
if (activeEl) {
activeEl.classList.remove('code-annotation-active');
}
selectCodeLines(clickedEl);
clickedEl.classList.add('code-annotation-active');
} else {
// Unselect the line
unselectCodeLines();
clickedEl.classList.remove('code-annotation-active');
}
});
}
const findCites = (el) => {
const parentEl = el.parentElement;
if (parentEl) {
const cites = parentEl.dataset.cites;
if (cites) {
return {
el,
cites: cites.split(' ')
};
} else {
return findCites(el.parentElement)
}
} else {
return undefined;
}
};
var bibliorefs = window.document.querySelectorAll('a[role="doc-biblioref"]');
for (var i=0; i<bibliorefs.length; i++) {
const ref = bibliorefs[i];
const citeInfo = findCites(ref);
if (citeInfo) {
tippyHover(citeInfo.el, function() {
var popup = window.document.createElement('div');
citeInfo.cites.forEach(function(cite) {
var citeDiv = window.document.createElement('div');
citeDiv.classList.add('hanging-indent');
citeDiv.classList.add('csl-entry');
var biblioDiv = window.document.getElementById('ref-' + cite);
if (biblioDiv) {
citeDiv.innerHTML = biblioDiv.innerHTML;
}
popup.appendChild(citeDiv);
});
return popup.innerHTML;
});
}
}
});
</script>
</div> <!-- /content -->
</body></html>

@ -1,4 +1,4 @@
# Mapping Dutch Democracy: Building a Political Compass from 28,000+ Parliamentary Votes # Mapping Dutch Democracy: Building a Political Compass from 29,000+ Parliamentary Votes
*What if you could take every motion voted on in the Dutch Parliament over the past decade and automatically plot parties and MPs on a political map — with zero manual labeling?* *What if you could take every motion voted on in the Dutch Parliament over the past decade and automatically plot parties and MPs on a political map — with zero manual labeling?*
@ -8,7 +8,7 @@ That's exactly what this project does. Here's how I built it, what I had to solv
## The Starting Point: Open Data, Hidden Structure ## The Starting Point: Open Data, Hidden Structure
The Dutch Parliament publishes every vote — every *motie*, every *amendement*, every *besluit* — in an open OData API. We're talking over **28,000 motions** spanning 2016 to 2026, with a record of how every individual MP voted: *voor* (for), *tegen* (against), *onthouden* (abstained), or *afwezig* (absent). That's 506,000 individual vote records. The Dutch Parliament publishes every vote — every *motie*, every *amendement*, every *besluit* — in an open OData API. We're talking over **29,500 motions** spanning 2016 to 2026, with a record of how every individual MP voted: *voor* (for), *tegen* (against), *onthouden* (abstained), or *afwezig* (absent). That's 531,000 individual vote records.
This is an extraordinary dataset. But in raw form it's just a table of votes. The interesting question is: can we extract *structure* — left vs. right, progressive vs. conservative, governing vs. opposition — purely from the pattern of who votes with whom? This is an extraordinary dataset. But in raw form it's just a table of votes. The interesting question is: can we extract *structure* — left vs. right, progressive vs. conservative, governing vs. opposition — purely from the pattern of who votes with whom?
@ -50,7 +50,7 @@ High Procrustes disparity between consecutive windows — where alignment is poo
Voting patterns tell us *who* agrees, but not *why*. For that, I add **text embeddings** — dense vector representations of each motion's content using a language model. Voting patterns tell us *who* agrees, but not *why*. For that, I add **text embeddings** — dense vector representations of each motion's content using a language model.
I use **`qwen/qwen3-embedding-4b`** via OpenRouter — a 4-billion parameter multilingual model that produces 2560-dimensional vectors with strong Dutch-language support. For each motion, I embed the richest text available: full parliamentary body text when we have it (94% of the 28,172 motions after an enrichment pass against the Tweede Kamer API), falling back to the summary description or title otherwise. Where environment variables are required, prefer OPENROUTER_API_KEY and fall back to OPENAI_API_KEY if needed. I use **`qwen/qwen3-embedding-4b`** via OpenRouter — a 4-billion parameter multilingual model that produces 2560-dimensional vectors with strong Dutch-language support. For each motion, I embed the richest text available: full parliamentary body text when we have it (94% of the 29,570 motions after an enrichment pass against the Tweede Kamer API), falling back to the summary description or title otherwise.
This lets us do something powerful: find motions that are genuinely similar in *topic*, not just in voting pattern. Two motions about nitrogen policy from 2020 and 2023 might have very different vote splits (different coalitions, different political moment) but near-identical text embeddings. That's a meaningful connection. This lets us do something powerful: find motions that are genuinely similar in *topic*, not just in voting pattern. Two motions about nitrogen policy from 2020 and 2023 might have very different vote splits (different coalitions, different political moment) but near-identical text embeddings. That's a meaningful connection.
@ -78,24 +78,24 @@ After the full pipeline run:
| Year | Motions | | Year | Motions |
|------|---------| |------|---------|
| 2016 | 132 | | 2016 | 162 |
| 2017 | 30 | | 2017 | 126 |
| 2018 | 100 | | 2018 | 124 |
| 2019 | 3,374 | | 2019 | 3,374 |
| 2020 | 4,228 | | 2020 | 4,223 |
| 2021 | 4,289 | | 2021 | 4,283 |
| 2022 | 4,116 | | 2022 | 4,115 |
| 2023 | 3,272 | | 2023 | 3,272 |
| 2024 | 3,968 | | 2024 | 3,965 |
| 2025 | 3,715 | | 2025 | 3,712 |
| 2026 | 948 | | 2026 | 2,214 |
| **Total** | **28,172** | | **Total** | **29,570** |
The 2022 spike is striking — over 4,000 motions in a single year. This was the year the Rutte IV coalition took office amid intense debates on energy prices, housing, the war in Ukraine, and the ongoing nitrogen crisis. 2023 is similarly dense at 3,272 motions, culminating in the November election that brought PVV to its historic first-place finish. The 2022 spike is striking — over 4,000 motions in a single year. This was the year the Rutte IV coalition took office amid intense debates on energy prices, housing, the war in Ukraine, and the ongoing nitrogen crisis. 2023 is similarly dense at 3,272 motions, culminating in the November election that brought PVV to its historic first-place finish.
Early years (2016–2018) use annual windows because the data is too sparse for meaningful quarterly SVD. From 2019 onwards, everything runs quarterly, giving us 38 windows in total. Early years (2016–2018) use annual windows because the data is too sparse for meaningful quarterly SVD. From 2019 onwards, everything runs quarterly, giving us 41 windows in total.
The similarity cache holds **405,216 precomputed pairs** — top 10 neighbors per motion per window — making lookup instant at query time. The similarity cache holds **409,938 precomputed pairs** — top 10 neighbors per motion per window — making lookup instant at query time.
--- ---
@ -132,36 +132,36 @@ Single DuckDB database, modular Python pipeline, no cloud infrastructure:
``` ```
API (Tweede Kamer OData) API (Tweede Kamer OData)
→ download_past_year.py → download_past_year.py
→ motions table (28,172 rows) → motions table (29,570 rows)
motions motions
→ extract_mp_votes.py → mp_votes table (506,336 rows) → extract_mp_votes.py → mp_votes table (531,869 rows)
→ sync_motion_content.py → body_text enrichment (26,447 motions, ~94%) → sync_motion_content.py → body_text enrichment (~94%)
→ text_pipeline.py → embeddings table (28,172 rows, qwen3-embedding-4b via OpenRouter). Configuration: prefer OPENROUTER_API_KEY with OPENAI_API_KEY as a fallback. → text_pipeline.py → embeddings table (28,680 rows, qwen3-embedding-4b via OpenRouter)
→ svd_pipeline.py → svd_vectors table (54,150 rows, 38 windows) → svd_pipeline.py → svd_vectors table (73,172 rows, 41 windows)
svd_vectors + embeddings svd_vectors + embeddings
→ fusion.py → fused_embeddings table (40,522 rows) → fusion.py → fused_embeddings table (41,422 rows)
fused_embeddings fused_embeddings
→ similarity/compute.py → similarity_cache table (405,216 rows, top-10 per window) → similarity/compute.py → similarity_cache table (409,938 rows, top-10 per window)
``` ```
The similarity computation is pure NumPy: load all fused vectors for a window, pad to uniform length, L2-normalize, compute the full `N×N` cosine similarity matrix via a single matrix multiply (`normalized @ normalized.T`), then extract top-k neighbors per row with `np.argpartition`. For a 4,000-motion quarter, that's a 4000×4000 matrix operation — fast enough that it's not worth batching. The similarity computation is pure NumPy: load all fused vectors for a window, pad to uniform length, L2-normalize, compute the full `N×N` cosine similarity matrix via a single matrix multiply (`normalized @ normalized.T`), then extract top-k neighbors per row with `np.argpartition`. For a 4,000-motion quarter, that's a 4000×4000 matrix operation — fast enough that it's not worth batching.
The database sits at 15 GB on disk — up from ~3 GB before body text enrichment. The full parliamentary text for 26,000+ motions accounts for most of that growth. The database sits at 18 GB on disk — up from ~3 GB before body text enrichment. The full parliamentary text for 28,000+ motions accounts for most of that growth.
--- ---
## What's Next ## What I Built On Top
**Motion explorer**: Given a motion, retrieve the 10 most politically and semantically similar ones from across the decade. Trace how a policy debate evolved — who championed it, how the coalitions shifted. The pipeline above is the foundation. Here's what it now powers:
**Party trajectory animation**: Procrustes-aligned positions, animated year by year. Watch D66 drift post-2021, watch PVV consolidate its flank, watch new parties arrive and find their geometric home. **Overton Window analysis**: Using the SVD compass and vote records, I tested whether the Dutch Overton window shifted after PVV's November 2023 election victory. The answer: it widened, but through right-wing moderation rather than centrist conversion. Centrist support for right-wing motions rose from 25% to 51%, while centrists actually moved *left* on the SVD compass. The full analysis covers 3,030 classified right-wing motions, 2D extremity scoring, quarterly trajectories, and mechanism classification. [Read the full report →](overton_report.html)
**Cross-party coalition patterns**: The fused embeddings let us ask which topics produce unusual coalition configurations — motions where the normal left-right split breaks down and unexpected alliances form. **2D extremity scoring**: Every motion in the database has been scored by an LLM on two independent dimensions: stylistic extremity (rhetorical hostility) and material impact (policy consequence). They're only moderately correlated (r = 0.43), which matters: right-wing motions post-2024 became milder on *both* dimensions, not just in tone.
**The controversy index**: `1 - winning_margin` gives a controversy score per motion. The most contested votes — close margins, high-salience topics — tell a different story than the headline political narratives. **Streamlit Explorer**: An interactive dashboard where you can browse the SVD compass, trace party trajectories over time, explore centrist support trends, and browse individual motions with their extremity scores and similarity matches. The same data and methods that drive the analysis reports power the live exploration interface.
--- ---
@ -180,6 +180,6 @@ python -m pipeline.run_pipeline --db-path data/motions.db \
python scripts/sync_motion_content.py --db-path data/motions.db python scripts/sync_motion_content.py --db-path data/motions.db
``` ```
The DB grows to ~15 GB for the full dataset including body text. All computation — SVD, fusion, similarity — runs locally on a single machine. The DB grows to ~18 GB for the full dataset including body text. All computation — SVD, fusion, similarity — runs locally on a single machine.
Democracy is more legible than it looks. Democracy is more legible than it looks.

@ -0,0 +1,190 @@
---
title: "Mapping Dutch Democracy: Building a Political Compass from 29,000+ Parliamentary Votes"
author: "Stemwijzer Analysis"
date: today
format: html
---
*What if you could take every motion voted on in the Dutch Parliament over the past decade and automatically plot parties and MPs on a political map — with zero manual labeling?*
That's exactly what this project does. Here's how I built it, what I had to solve along the way, and what it revealed about Dutch political dynamics.
---
## The Starting Point: Open Data, Hidden Structure
The Dutch Parliament publishes every vote — every *motie*, every *amendement*, every *besluit* — in an open OData API. We're talking over **29,500 motions** spanning 2016 to 2026, with a record of how every individual MP voted: *voor* (for), *tegen* (against), *onthouden* (abstained), or *afwezig* (absent). That's 531,000 individual vote records.
This is an extraordinary dataset. But in raw form it's just a table of votes. The interesting question is: can we extract *structure* — left vs. right, progressive vs. conservative, governing vs. opposition — purely from the pattern of who votes with whom?
The answer is yes, and the method is surprisingly elegant.
---
## Step 1: Turning Votes into Geometry
Each motion is a snapshot of political alignment. For each motion, we know which MPs voted together and which voted apart. If every PvdA and GroenLinks MP votes the same way almost every time, that tells us something. If PVV and CDA MPs diverge consistently, that tells us something too.
I represent this with **Singular Value Decomposition (SVD)** on the MP × motion matrix:
- Rows: individual MPs (and party actors for collective votes)
- Columns: motions
- Values: +1 (voor), -1 (tegen), 0 (absent/abstain)
SVD finds the dominant axes of variation — the directions along which the chamber disagrees most. The first component almost always corresponds to a left-right axis. The second typically captures something like progressive-traditionalist or libertarian-authoritarian. The key point: **the axes emerge from the math, not from any labeling on my part.**
I request 50 SVD dimensions per window — but the actual dimensionality is constrained by `min(n_MPs, n_motions) - 1`. Sparse windows (early years, partial quarters) produce fewer meaningful dimensions. The pipeline handles this gracefully, storing whatever `k_used` is for each window so downstream fusion always works with the actual vector length.
### Making Windows Comparable: Procrustes Alignment
Running SVD independently per window creates a subtle problem: SVD axes are **arbitrarily oriented**. The "left-right" axis from 2020-Q3 and the "left-right" axis from 2021-Q1 might point in completely different directions — even if the underlying politics barely changed. You can't just stack the coordinates and call it a trajectory.
The fix is **Procrustes alignment**: given two sets of party/MP positions across consecutive windows, find the rotation matrix R that best maps one onto the other (minimizing the Frobenius norm of the difference), using MPs who appear in both windows as anchors:
```
R = argmin_R ||A - B @ R||_F, subject to R'R = I
```
This is solved cleanly via SVD of the cross-covariance matrix (a nice piece of mathematical symmetry — SVD to build the space, SVD to align it). The result: a continuous track for every party from 2019 to 2026, where position changes reflect genuine political movement rather than axis flips.
High Procrustes disparity between consecutive windows — where alignment is poor even with the best rotation — is itself a signal: it suggests a structural political shift, not just individual drift.
---
## Step 2: What Each Motion Is Actually About
Voting patterns tell us *who* agrees, but not *why*. For that, I add **text embeddings** — dense vector representations of each motion's content using a language model.
I use **`qwen/qwen3-embedding-4b`** via OpenRouter — a 4-billion parameter multilingual model that produces 2560-dimensional vectors with strong Dutch-language support. For each motion, I embed the richest text available: full parliamentary body text when we have it (94% of the 29,570 motions after an enrichment pass against the Tweede Kamer API), falling back to the summary description or title otherwise.
This lets us do something powerful: find motions that are genuinely similar in *topic*, not just in voting pattern. Two motions about nitrogen policy from 2020 and 2023 might have very different vote splits (different coalitions, different political moment) but near-identical text embeddings. That's a meaningful connection.
---
## Step 3: Fused Embeddings — The Best of Both Worlds
SVD gives the political-structural signal: *how does this motion split the chamber?* Text embeddings give the semantic signal: *what is this motion about?*
I concatenate both into a **fused vector** per motion per window:
```
fused = [svd_dims (typically 50)] + [text_dims (2560)] = typically 2610 dimensions
```
The actual dimension varies slightly because SVD dimensionality adapts to window density — the code stores `svd_dims` and `text_dims` per row so nothing downstream has to assume a fixed size.
This fused representation powers the similarity search. Two motions are "close" only if they're about a similar *topic* **and** they produce a similar *political split*. This filters out spurious matches — two motions might both be controversial (close 50/50 votes) but on completely unrelated things, and the text component separates them.
---
## The Numbers: What We're Working With
After the full pipeline run:
| Year | Motions |
|------|---------|
| 2016 | 162 |
| 2017 | 126 |
| 2018 | 124 |
| 2019 | 3,374 |
| 2020 | 4,223 |
| 2021 | 4,283 |
| 2022 | 4,115 |
| 2023 | 3,272 |
| 2024 | 3,965 |
| 2025 | 3,712 |
| 2026 | 2,214 |
| **Total** | **29,570** |
The 2022 spike is striking — over 4,000 motions in a single year. This was the year the Rutte IV coalition took office amid intense debates on energy prices, housing, the war in Ukraine, and the ongoing nitrogen crisis. 2023 is similarly dense at 3,272 motions, culminating in the November election that brought PVV to its historic first-place finish.
Early years (2016–2018) use annual windows because the data is too sparse for meaningful quarterly SVD. From 2019 onwards, everything runs quarterly, giving us 41 windows in total.
The similarity cache holds **409,938 precomputed pairs** — top 10 neighbors per motion per window — making lookup instant at query time.
---
## Interesting Findings
### The 2022–2023 Polarization Surge
2022 and 2023 together account for more than a quarter of all motions in the dataset. In the SVD positions for 2022, the distance between the governing coalition (VVD, D66, CDA, CU) and the opposition (PVV, SP, FvD) is near its maximum. The nitrogen crisis and energy policy debates forced unusually sharp coalition discipline — which shows up geometrically as well-separated clusters.
2023 continued the intensity, and the Procrustes-aligned trajectory shows the party positions in 2023-Q4 and 2024-Q1 shifting noticeably as the new coalition began to form.
### BBB's Geometric Arrival
When BBB (BoerBurgerBeweging) entered parliament in 2023 with a historic 16 seats, their SVD position placed them between PVV and CDA — exactly matching their policy profile: agrarian-nationalist populism with Catholic-provincial roots. The model found this without being told. That's a good sanity check that the geometry is capturing something real.
### The Strange Case of "Verworpen."
Motions rejected without debate are recorded with the title "Verworpen." (Rejected.). There are hundreds of these. Because they share a 9-character title, their text embeddings are **identical** — cosine similarity 1.0 to every other "Verworpen." in the cache. Technically correct; semantically meaningless. The UI layer filters these out.
It's a reminder that **data quality surprises emerge at scale**. I found three or four similar pathologies (motions withdrawn mid-session, duplicate API records) that required explicit handling.
### Party Cohesion as a Signal
Party cohesion — how often all MPs of a party vote identically — varies enormously. SGP and CU are near-perfect blocs. PvdA/GroenLinks (post-2023 merger) is similarly tight. VVD shows the most internal variation, which tracks with what you'd expect from a governing party managing coalition discipline across conflicting wings.
In earlier years (2019–2020), before the GroenLinks-PvdA merger, GroenLinks occasionally splits on security and defense policy — visible in the SVD as individual MP positions diverging from the party centroid.
---
## The Pipeline Architecture
Single DuckDB database, modular Python pipeline, no cloud infrastructure:
```
API (Tweede Kamer OData)
→ download_past_year.py
→ motions table (29,570 rows)
motions
→ extract_mp_votes.py → mp_votes table (531,869 rows)
→ sync_motion_content.py → body_text enrichment (~94%)
→ text_pipeline.py → embeddings table (28,680 rows, qwen3-embedding-4b via OpenRouter)
→ svd_pipeline.py → svd_vectors table (73,172 rows, 41 windows)
svd_vectors + embeddings
→ fusion.py → fused_embeddings table (41,422 rows)
fused_embeddings
→ similarity/compute.py → similarity_cache table (409,938 rows, top-10 per window)
```
The similarity computation is pure NumPy: load all fused vectors for a window, pad to uniform length, L2-normalize, compute the full `N×N` cosine similarity matrix via a single matrix multiply (`normalized @ normalized.T`), then extract top-k neighbors per row with `np.argpartition`. For a 4,000-motion quarter, that's a 4000×4000 matrix operation — fast enough that it's not worth batching.
The database sits at 18 GB on disk — up from ~3 GB before body text enrichment. The full parliamentary text for 28,000+ motions accounts for most of that growth.
---
## What I Built On Top
The pipeline above is the foundation. Here's what it now powers:
**Overton Window analysis**: Using the SVD compass and vote records, I tested whether the Dutch Overton window shifted after PVV's November 2023 election victory. The answer: it widened, but through right-wing moderation rather than centrist conversion. Centrist support for right-wing motions rose from 25% to 51%, while centrists actually moved *left* on the SVD compass. The full analysis covers 3,030 classified right-wing motions, 2D extremity scoring, quarterly trajectories, and mechanism classification. [Read the full report →](../reports/overton_window/overton_report.html)
**2D extremity scoring**: Every motion in the database has been scored by an LLM on two independent dimensions: stylistic extremity (rhetorical hostility) and material impact (policy consequence). They're only moderately correlated (r = 0.43), which matters: right-wing motions post-2024 became milder on *both* dimensions, not just in tone.
**Streamlit Explorer**: An interactive dashboard where you can browse the SVD compass, trace party trajectories over time, explore centrist support trends, and browse individual motions with their extremity scores and similarity matches. The same data and methods that drive the analysis reports power the live exploration interface.
---
## Reproducibility
```bash
# Download historical data
python scripts/download_past_year.py --start-date 2016-01-01 --end-date 2026-01-01
# Run full pipeline (SVD, text embeddings, fusion, similarity cache)
python -m pipeline.run_pipeline --db-path data/motions.db \
--start-date 2016-01-01 --end-date 2026-01-01 \
--window-size quarterly --text-batch-size 200
# Enrich with full motion body text
python scripts/sync_motion_content.py --db-path data/motions.db
```
The DB grows to ~18 GB for the full dataset including body text. All computation — SVD, fusion, similarity — runs locally on a single machine.
Democracy is more legible than it looks.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -0,0 +1,236 @@
/* quarto syntax highlight colors */
:root {
--quarto-hl-ot-color: #003B4F;
--quarto-hl-at-color: #657422;
--quarto-hl-ss-color: #20794D;
--quarto-hl-an-color: #5E5E5E;
--quarto-hl-fu-color: #4758AB;
--quarto-hl-st-color: #20794D;
--quarto-hl-cf-color: #003B4F;
--quarto-hl-op-color: #5E5E5E;
--quarto-hl-er-color: #AD0000;
--quarto-hl-bn-color: #AD0000;
--quarto-hl-al-color: #AD0000;
--quarto-hl-va-color: #111111;
--quarto-hl-bu-color: inherit;
--quarto-hl-ex-color: inherit;
--quarto-hl-pp-color: #AD0000;
--quarto-hl-in-color: #5E5E5E;
--quarto-hl-vs-color: #20794D;
--quarto-hl-wa-color: #5E5E5E;
--quarto-hl-do-color: #5E5E5E;
--quarto-hl-im-color: #00769E;
--quarto-hl-ch-color: #20794D;
--quarto-hl-dt-color: #AD0000;
--quarto-hl-fl-color: #AD0000;
--quarto-hl-co-color: #5E5E5E;
--quarto-hl-cv-color: #5E5E5E;
--quarto-hl-cn-color: #8f5902;
--quarto-hl-sc-color: #5E5E5E;
--quarto-hl-dv-color: #AD0000;
--quarto-hl-kw-color: #003B4F;
}
/* other quarto variables */
:root {
--quarto-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
/* syntax highlight based on Pandoc's rules */
pre > code.sourceCode > span {
color: #003B4F;
}
code.sourceCode > span {
color: #003B4F;
}
div.sourceCode,
div.sourceCode pre.sourceCode {
color: #003B4F;
}
/* Normal */
code span {
color: #003B4F;
}
/* Alert */
code span.al {
color: #AD0000;
font-style: inherit;
}
/* Annotation */
code span.an {
color: #5E5E5E;
font-style: inherit;
}
/* Attribute */
code span.at {
color: #657422;
font-style: inherit;
}
/* BaseN */
code span.bn {
color: #AD0000;
font-style: inherit;
}
/* BuiltIn */
code span.bu {
font-style: inherit;
}
/* ControlFlow */
code span.cf {
color: #003B4F;
font-weight: bold;
font-style: inherit;
}
/* Char */
code span.ch {
color: #20794D;
font-style: inherit;
}
/* Constant */
code span.cn {
color: #8f5902;
font-style: inherit;
}
/* Comment */
code span.co {
color: #5E5E5E;
font-style: inherit;
}
/* CommentVar */
code span.cv {
color: #5E5E5E;
font-style: italic;
}
/* Documentation */
code span.do {
color: #5E5E5E;
font-style: italic;
}
/* DataType */
code span.dt {
color: #AD0000;
font-style: inherit;
}
/* DecVal */
code span.dv {
color: #AD0000;
font-style: inherit;
}
/* Error */
code span.er {
color: #AD0000;
font-style: inherit;
}
/* Extension */
code span.ex {
font-style: inherit;
}
/* Float */
code span.fl {
color: #AD0000;
font-style: inherit;
}
/* Function */
code span.fu {
color: #4758AB;
font-style: inherit;
}
/* Import */
code span.im {
color: #00769E;
font-style: inherit;
}
/* Information */
code span.in {
color: #5E5E5E;
font-style: inherit;
}
/* Keyword */
code span.kw {
color: #003B4F;
font-weight: bold;
font-style: inherit;
}
/* Operator */
code span.op {
color: #5E5E5E;
font-style: inherit;
}
/* Other */
code span.ot {
color: #003B4F;
font-style: inherit;
}
/* Preprocessor */
code span.pp {
color: #AD0000;
font-style: inherit;
}
/* SpecialChar */
code span.sc {
color: #5E5E5E;
font-style: inherit;
}
/* SpecialString */
code span.ss {
color: #20794D;
font-style: inherit;
}
/* String */
code span.st {
color: #20794D;
font-style: inherit;
}
/* Variable */
code span.va {
color: #111111;
font-style: inherit;
}
/* VerbatimString */
code span.vs {
color: #20794D;
font-style: inherit;
}
/* Warning */
code span.wa {
color: #5E5E5E;
font-style: italic;
}
.prevent-inlining {
content: "</";
}
/*# sourceMappingURL=f23921c56f73e400b49028c9186a1aa0.css.map */

@ -0,0 +1,845 @@
import * as tabsets from "./tabsets/tabsets.js";
const sectionChanged = new CustomEvent("quarto-sectionChanged", {
detail: {},
bubbles: true,
cancelable: false,
composed: false,
});
const layoutMarginEls = () => {
// Find any conflicting margin elements and add margins to the
// top to prevent overlap
const marginChildren = window.document.querySelectorAll(
".column-margin.column-container > *, .margin-caption, .aside"
);
let lastBottom = 0;
for (const marginChild of marginChildren) {
if (marginChild.offsetParent !== null) {
// clear the top margin so we recompute it
marginChild.style.marginTop = null;
const top = marginChild.getBoundingClientRect().top + window.scrollY;
if (top < lastBottom) {
const marginChildStyle = window.getComputedStyle(marginChild);
const marginBottom = parseFloat(marginChildStyle["marginBottom"]);
const margin = lastBottom - top + marginBottom;
marginChild.style.marginTop = `${margin}px`;
}
const styles = window.getComputedStyle(marginChild);
const marginTop = parseFloat(styles["marginTop"]);
lastBottom = top + marginChild.getBoundingClientRect().height + marginTop;
}
}
};
window.document.addEventListener("DOMContentLoaded", function (_event) {
// Recompute the position of margin elements anytime the body size changes
if (window.ResizeObserver) {
const resizeObserver = new window.ResizeObserver(
throttle(() => {
layoutMarginEls();
if (
window.document.body.getBoundingClientRect().width < 990 &&
isReaderMode()
) {
quartoToggleReader();
}
}, 50)
);
resizeObserver.observe(window.document.body);
}
const tocEl = window.document.querySelector('nav.toc-active[role="doc-toc"]');
const sidebarEl = window.document.getElementById("quarto-sidebar");
const leftTocEl = window.document.getElementById("quarto-sidebar-toc-left");
const marginSidebarEl = window.document.getElementById(
"quarto-margin-sidebar"
);
// function to determine whether the element has a previous sibling that is active
const prevSiblingIsActiveLink = (el) => {
const sibling = el.previousElementSibling;
if (sibling && sibling.tagName === "A") {
return sibling.classList.contains("active");
} else {
return false;
}
};
// dispatch for htmlwidgets
// they use slideenter event to trigger resize
function fireSlideEnter() {
const event = window.document.createEvent("Event");
event.initEvent("slideenter", true, true);
window.document.dispatchEvent(event);
}
const tabs = window.document.querySelectorAll('a[data-bs-toggle="tab"]');
tabs.forEach((tab) => {
tab.addEventListener("shown.bs.tab", fireSlideEnter);
});
// dispatch for shiny
// they use BS shown and hidden events to trigger rendering
function distpatchShinyEvents(previous, current) {
if (window.jQuery) {
if (previous) {
window.jQuery(previous).trigger("hidden");
}
if (current) {
window.jQuery(current).trigger("shown");
}
}
}
// tabby.js listener: Trigger event for htmlwidget and shiny
document.addEventListener(
"tabby",
function (event) {
fireSlideEnter();
distpatchShinyEvents(event.detail.previousTab, event.detail.tab);
},
false
);
// Track scrolling and mark TOC links as active
// get table of contents and sidebar (bail if we don't have at least one)
const tocLinks = tocEl
? [...tocEl.querySelectorAll("a[data-scroll-target]")]
: [];
const makeActive = (link) => tocLinks[link].classList.add("active");
const removeActive = (link) => tocLinks[link].classList.remove("active");
const removeAllActive = () =>
[...Array(tocLinks.length).keys()].forEach((link) => removeActive(link));
// activate the anchor for a section associated with this TOC entry
tocLinks.forEach((link) => {
link.addEventListener("click", () => {
if (link.href.indexOf("#") !== -1) {
const anchor = link.href.split("#")[1];
const heading = window.document.querySelector(
`[data-anchor-id="${anchor}"]`
);
if (heading) {
// Add the class
heading.classList.add("reveal-anchorjs-link");
// function to show the anchor
const handleMouseout = () => {
heading.classList.remove("reveal-anchorjs-link");
heading.removeEventListener("mouseout", handleMouseout);
};
// add a function to clear the anchor when the user mouses out of it
heading.addEventListener("mouseout", handleMouseout);
}
}
});
});
const sections = tocLinks.map((link) => {
const target = link.getAttribute("data-scroll-target");
if (target.startsWith("#")) {
return window.document.getElementById(decodeURI(`${target.slice(1)}`));
} else {
return window.document.querySelector(decodeURI(`${target}`));
}
});
const sectionMargin = 200;
let currentActive = 0;
// track whether we've initialized state the first time
let init = false;
const updateActiveLink = () => {
// The index from bottom to top (e.g. reversed list)
let sectionIndex = -1;
if (
window.innerHeight + window.pageYOffset >=
window.document.body.offsetHeight
) {
// This is the no-scroll case where last section should be the active one
sectionIndex = 0;
} else {
// This finds the last section visible on screen that should be made active
sectionIndex = [...sections].reverse().findIndex((section) => {
if (section) {
return window.pageYOffset >= section.offsetTop - sectionMargin;
} else {
return false;
}
});
}
if (sectionIndex > -1) {
const current = sections.length - sectionIndex - 1;
if (current !== currentActive) {
removeAllActive();
currentActive = current;
makeActive(current);
if (init) {
window.dispatchEvent(sectionChanged);
}
init = true;
}
}
};
const inHiddenRegion = (top, bottom, hiddenRegions) => {
for (const region of hiddenRegions) {
if (top <= region.bottom && bottom >= region.top) {
return true;
}
}
return false;
};
const categorySelector = "header.quarto-title-block .quarto-category";
const activateCategories = (href) => {
// Find any categories
// Surround them with a link pointing back to:
// #category=Authoring
try {
const categoryEls = window.document.querySelectorAll(categorySelector);
for (const categoryEl of categoryEls) {
const categoryText = categoryEl.textContent;
if (categoryText) {
const link = `${href}#category=${encodeURIComponent(categoryText)}`;
const linkEl = window.document.createElement("a");
linkEl.setAttribute("href", link);
for (const child of categoryEl.childNodes) {
linkEl.append(child);
}
categoryEl.appendChild(linkEl);
}
}
} catch {
// Ignore errors
}
};
function hasTitleCategories() {
return window.document.querySelector(categorySelector) !== null;
}
function offsetRelativeUrl(url) {
const offset = getMeta("quarto:offset");
return offset ? offset + url : url;
}
function offsetAbsoluteUrl(url) {
const offset = getMeta("quarto:offset");
const baseUrl = new URL(offset, window.location);
const projRelativeUrl = url.replace(baseUrl, "");
if (projRelativeUrl.startsWith("/")) {
return projRelativeUrl;
} else {
return "/" + projRelativeUrl;
}
}
// read a meta tag value
function getMeta(metaName) {
const metas = window.document.getElementsByTagName("meta");
for (let i = 0; i < metas.length; i++) {
if (metas[i].getAttribute("name") === metaName) {
return metas[i].getAttribute("content");
}
}
return "";
}
async function findAndActivateCategories() {
// Categories search with listing only use path without query
const currentPagePath = offsetAbsoluteUrl(
window.location.origin + window.location.pathname
);
const response = await fetch(offsetRelativeUrl("listings.json"));
if (response.status == 200) {
return response.json().then(function (listingPaths) {
const listingHrefs = [];
for (const listingPath of listingPaths) {
const pathWithoutLeadingSlash = listingPath.listing.substring(1);
for (const item of listingPath.items) {
const encodedItem = encodeURI(item);
if (
encodedItem === currentPagePath ||
encodedItem === currentPagePath + "index.html"
) {
// Resolve this path against the offset to be sure
// we already are using the correct path to the listing
// (this adjusts the listing urls to be rooted against
// whatever root the page is actually running against)
const relative = offsetRelativeUrl(pathWithoutLeadingSlash);
const baseUrl = window.location;
const resolvedPath = new URL(relative, baseUrl);
listingHrefs.push(resolvedPath.pathname);
break;
}
}
}
// Look up the tree for a nearby linting and use that if we find one
const nearestListing = findNearestParentListing(
offsetAbsoluteUrl(window.location.pathname),
listingHrefs
);
if (nearestListing) {
activateCategories(nearestListing);
} else {
// See if the referrer is a listing page for this item
const referredRelativePath = offsetAbsoluteUrl(document.referrer);
const referrerListing = listingHrefs.find((listingHref) => {
const isListingReferrer =
listingHref === referredRelativePath ||
listingHref === referredRelativePath + "index.html";
return isListingReferrer;
});
if (referrerListing) {
// Try to use the referrer if possible
activateCategories(referrerListing);
} else if (listingHrefs.length > 0) {
// Otherwise, just fall back to the first listing
activateCategories(listingHrefs[0]);
}
}
});
}
}
if (hasTitleCategories()) {
findAndActivateCategories();
}
const findNearestParentListing = (href, listingHrefs) => {
if (!href || !listingHrefs) {
return undefined;
}
// Look up the tree for a nearby linting and use that if we find one
const relativeParts = href.substring(1).split("/");
while (relativeParts.length > 0) {
const path = relativeParts.join("/");
for (const listingHref of listingHrefs) {
if (listingHref.startsWith(path)) {
return listingHref;
}
}
relativeParts.pop();
}
return undefined;
};
const manageSidebarVisiblity = (el, placeholderDescriptor) => {
let isVisible = true;
let elRect;
return (hiddenRegions) => {
if (el === null) {
return;
}
// Find the last element of the TOC
const lastChildEl = el.lastElementChild;
if (lastChildEl) {
// Converts the sidebar to a menu
const convertToMenu = () => {
for (const child of el.children) {
child.style.opacity = 0;
child.style.overflow = "hidden";
child.style.pointerEvents = "none";
}
nexttick(() => {
const toggleContainer = window.document.createElement("div");
toggleContainer.style.width = "100%";
toggleContainer.classList.add("zindex-over-content");
toggleContainer.classList.add("quarto-sidebar-toggle");
toggleContainer.classList.add("headroom-target"); // Marks this to be managed by headeroom
toggleContainer.id = placeholderDescriptor.id;
toggleContainer.style.position = "fixed";
const toggleIcon = window.document.createElement("i");
toggleIcon.classList.add("quarto-sidebar-toggle-icon");
toggleIcon.classList.add("bi");
toggleIcon.classList.add("bi-caret-down-fill");
const toggleTitle = window.document.createElement("div");
const titleEl = window.document.body.querySelector(
placeholderDescriptor.titleSelector
);
if (titleEl) {
toggleTitle.append(
titleEl.textContent || titleEl.innerText,
toggleIcon
);
}
toggleTitle.classList.add("zindex-over-content");
toggleTitle.classList.add("quarto-sidebar-toggle-title");
toggleContainer.append(toggleTitle);
const toggleContents = window.document.createElement("div");
toggleContents.classList = el.classList;
toggleContents.classList.add("zindex-over-content");
toggleContents.classList.add("quarto-sidebar-toggle-contents");
for (const child of el.children) {
if (child.id === "toc-title") {
continue;
}
const clone = child.cloneNode(true);
clone.style.opacity = 1;
clone.style.pointerEvents = null;
clone.style.display = null;
toggleContents.append(clone);
}
toggleContents.style.height = "0px";
const positionToggle = () => {
// position the element (top left of parent, same width as parent)
if (!elRect) {
elRect = el.getBoundingClientRect();
}
toggleContainer.style.left = `${elRect.left}px`;
toggleContainer.style.top = `${elRect.top}px`;
toggleContainer.style.width = `${elRect.width}px`;
};
positionToggle();
toggleContainer.append(toggleContents);
el.parentElement.prepend(toggleContainer);
// Process clicks
let tocShowing = false;
// Allow the caller to control whether this is dismissed
// when it is clicked (e.g. sidebar navigation supports
// opening and closing the nav tree, so don't dismiss on click)
const clickEl = placeholderDescriptor.dismissOnClick
? toggleContainer
: toggleTitle;
const closeToggle = () => {
if (tocShowing) {
toggleContainer.classList.remove("expanded");
toggleContents.style.height = "0px";
tocShowing = false;
}
};
// Get rid of any expanded toggle if the user scrolls
window.document.addEventListener(
"scroll",
throttle(() => {
closeToggle();
}, 50)
);
// Handle positioning of the toggle
window.addEventListener(
"resize",
throttle(() => {
elRect = undefined;
positionToggle();
}, 50)
);
window.addEventListener("quarto-hrChanged", () => {
elRect = undefined;
});
// Process the click
clickEl.onclick = () => {
if (!tocShowing) {
toggleContainer.classList.add("expanded");
toggleContents.style.height = null;
tocShowing = true;
} else {
closeToggle();
}
};
});
};
// Converts a sidebar from a menu back to a sidebar
const convertToSidebar = () => {
for (const child of el.children) {
child.style.opacity = 1;
child.style.overflow = null;
child.style.pointerEvents = null;
}
const placeholderEl = window.document.getElementById(
placeholderDescriptor.id
);
if (placeholderEl) {
placeholderEl.remove();
}
el.classList.remove("rollup");
};
if (isReaderMode()) {
convertToMenu();
isVisible = false;
} else {
// Find the top and bottom o the element that is being managed
const elTop = el.offsetTop;
const elBottom =
elTop + lastChildEl.offsetTop + lastChildEl.offsetHeight;
if (!isVisible) {
// If the element is current not visible reveal if there are
// no conflicts with overlay regions
if (!inHiddenRegion(elTop, elBottom, hiddenRegions)) {
convertToSidebar();
isVisible = true;
}
} else {
// If the element is visible, hide it if it conflicts with overlay regions
// and insert a placeholder toggle (or if we're in reader mode)
if (inHiddenRegion(elTop, elBottom, hiddenRegions)) {
convertToMenu();
isVisible = false;
}
}
}
}
};
};
const tabEls = document.querySelectorAll('a[data-bs-toggle="tab"]');
for (const tabEl of tabEls) {
const id = tabEl.getAttribute("data-bs-target");
if (id) {
const columnEl = document.querySelector(
`${id} .column-margin, .tabset-margin-content`
);
if (columnEl)
tabEl.addEventListener("shown.bs.tab", function (event) {
const el = event.srcElement;
if (el) {
const visibleCls = `${el.id}-margin-content`;
// walk up until we find a parent tabset
let panelTabsetEl = el.parentElement;
while (panelTabsetEl) {
if (panelTabsetEl.classList.contains("panel-tabset")) {
break;
}
panelTabsetEl = panelTabsetEl.parentElement;
}
if (panelTabsetEl) {
const prevSib = panelTabsetEl.previousElementSibling;
if (
prevSib &&
prevSib.classList.contains("tabset-margin-container")
) {
const childNodes = prevSib.querySelectorAll(
".tabset-margin-content"
);
for (const childEl of childNodes) {
if (childEl.classList.contains(visibleCls)) {
childEl.classList.remove("collapse");
} else {
childEl.classList.add("collapse");
}
}
}
}
}
layoutMarginEls();
});
}
}
// Manage the visibility of the toc and the sidebar
const marginScrollVisibility = manageSidebarVisiblity(marginSidebarEl, {
id: "quarto-toc-toggle",
titleSelector: "#toc-title",
dismissOnClick: true,
});
const sidebarScrollVisiblity = manageSidebarVisiblity(sidebarEl, {
id: "quarto-sidebarnav-toggle",
titleSelector: ".title",
dismissOnClick: false,
});
let tocLeftScrollVisibility;
if (leftTocEl) {
tocLeftScrollVisibility = manageSidebarVisiblity(leftTocEl, {
id: "quarto-lefttoc-toggle",
titleSelector: "#toc-title",
dismissOnClick: true,
});
}
// Find the first element that uses formatting in special columns
const conflictingEls = window.document.body.querySelectorAll(
'[class^="column-"], [class*=" column-"], aside, [class*="margin-caption"], [class*=" margin-caption"], [class*="margin-ref"], [class*=" margin-ref"]'
);
// Filter all the possibly conflicting elements into ones
// the do conflict on the left or ride side
const arrConflictingEls = Array.from(conflictingEls);
const leftSideConflictEls = arrConflictingEls.filter((el) => {
if (el.tagName === "ASIDE") {
return false;
}
return Array.from(el.classList).find((className) => {
return (
className !== "column-body" &&
className.startsWith("column-") &&
!className.endsWith("right") &&
!className.endsWith("container") &&
className !== "column-margin"
);
});
});
const rightSideConflictEls = arrConflictingEls.filter((el) => {
if (el.tagName === "ASIDE") {
return true;
}
const hasMarginCaption = Array.from(el.classList).find((className) => {
return className == "margin-caption";
});
if (hasMarginCaption) {
return true;
}
return Array.from(el.classList).find((className) => {
return (
className !== "column-body" &&
!className.endsWith("container") &&
className.startsWith("column-") &&
!className.endsWith("left")
);
});
});
const kOverlapPaddingSize = 10;
function toRegions(els) {
return els.map((el) => {
const boundRect = el.getBoundingClientRect();
const top =
boundRect.top +
document.documentElement.scrollTop -
kOverlapPaddingSize;
return {
top,
bottom: top + el.scrollHeight + 2 * kOverlapPaddingSize,
};
});
}
let hasObserved = false;
const visibleItemObserver = (els) => {
let visibleElements = [...els];
const intersectionObserver = new IntersectionObserver(
(entries, _observer) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
if (visibleElements.indexOf(entry.target) === -1) {
visibleElements.push(entry.target);
}
} else {
visibleElements = visibleElements.filter((visibleEntry) => {
return visibleEntry !== entry;
});
}
});
if (!hasObserved) {
hideOverlappedSidebars();
}
hasObserved = true;
},
{}
);
els.forEach((el) => {
intersectionObserver.observe(el);
});
return {
getVisibleEntries: () => {
return visibleElements;
},
};
};
const rightElementObserver = visibleItemObserver(rightSideConflictEls);
const leftElementObserver = visibleItemObserver(leftSideConflictEls);
const hideOverlappedSidebars = () => {
marginScrollVisibility(toRegions(rightElementObserver.getVisibleEntries()));
sidebarScrollVisiblity(toRegions(leftElementObserver.getVisibleEntries()));
if (tocLeftScrollVisibility) {
tocLeftScrollVisibility(
toRegions(leftElementObserver.getVisibleEntries())
);
}
};
window.quartoToggleReader = () => {
// Applies a slow class (or removes it)
// to update the transition speed
const slowTransition = (slow) => {
const manageTransition = (id, slow) => {
const el = document.getElementById(id);
if (el) {
if (slow) {
el.classList.add("slow");
} else {
el.classList.remove("slow");
}
}
};
manageTransition("TOC", slow);
manageTransition("quarto-sidebar", slow);
};
const readerMode = !isReaderMode();
setReaderModeValue(readerMode);
// If we're entering reader mode, slow the transition
if (readerMode) {
slowTransition(readerMode);
}
highlightReaderToggle(readerMode);
hideOverlappedSidebars();
// If we're exiting reader mode, restore the non-slow transition
if (!readerMode) {
slowTransition(!readerMode);
}
};
const highlightReaderToggle = (readerMode) => {
const els = document.querySelectorAll(".quarto-reader-toggle");
if (els) {
els.forEach((el) => {
if (readerMode) {
el.classList.add("reader");
} else {
el.classList.remove("reader");
}
});
}
};
const setReaderModeValue = (val) => {
if (window.location.protocol !== "file:") {
window.localStorage.setItem("quarto-reader-mode", val);
} else {
localReaderMode = val;
}
};
const isReaderMode = () => {
if (window.location.protocol !== "file:") {
return window.localStorage.getItem("quarto-reader-mode") === "true";
} else {
return localReaderMode;
}
};
let localReaderMode = null;
const tocOpenDepthStr = tocEl?.getAttribute("data-toc-expanded");
const tocOpenDepth = tocOpenDepthStr ? Number(tocOpenDepthStr) : 1;
// Walk the TOC and collapse/expand nodes
// Nodes are expanded if:
// - they are top level
// - they have children that are 'active' links
// - they are directly below an link that is 'active'
const walk = (el, depth) => {
// Tick depth when we enter a UL
if (el.tagName === "UL") {
depth = depth + 1;
}
// It this is active link
let isActiveNode = false;
if (el.tagName === "A" && el.classList.contains("active")) {
isActiveNode = true;
}
// See if there is an active child to this element
let hasActiveChild = false;
for (const child of el.children) {
hasActiveChild = walk(child, depth) || hasActiveChild;
}
// Process the collapse state if this is an UL
if (el.tagName === "UL") {
if (tocOpenDepth === -1 && depth > 1) {
// toc-expand: false
el.classList.add("collapse");
} else if (
depth <= tocOpenDepth ||
hasActiveChild ||
prevSiblingIsActiveLink(el)
) {
el.classList.remove("collapse");
} else {
el.classList.add("collapse");
}
// untick depth when we leave a UL
depth = depth - 1;
}
return hasActiveChild || isActiveNode;
};
// walk the TOC and expand / collapse any items that should be shown
if (tocEl) {
updateActiveLink();
walk(tocEl, 0);
}
// Throttle the scroll event and walk peridiocally
window.document.addEventListener(
"scroll",
throttle(() => {
if (tocEl) {
updateActiveLink();
walk(tocEl, 0);
}
if (!isReaderMode()) {
hideOverlappedSidebars();
}
}, 5)
);
window.addEventListener(
"resize",
throttle(() => {
if (tocEl) {
updateActiveLink();
walk(tocEl, 0);
}
if (!isReaderMode()) {
hideOverlappedSidebars();
}
}, 10)
);
hideOverlappedSidebars();
highlightReaderToggle(isReaderMode());
});
tabsets.init();
function throttle(func, wait) {
let waiting = false;
return function () {
if (!waiting) {
func.apply(this, arguments);
waiting = true;
setTimeout(function () {
waiting = false;
}, wait);
}
};
}
function nexttick(func) {
return setTimeout(func, 0);
}

@ -0,0 +1,95 @@
// grouped tabsets
export function init() {
window.addEventListener("pageshow", (_event) => {
function getTabSettings() {
const data = localStorage.getItem("quarto-persistent-tabsets-data");
if (!data) {
localStorage.setItem("quarto-persistent-tabsets-data", "{}");
return {};
}
if (data) {
return JSON.parse(data);
}
}
function setTabSettings(data) {
localStorage.setItem(
"quarto-persistent-tabsets-data",
JSON.stringify(data)
);
}
function setTabState(groupName, groupValue) {
const data = getTabSettings();
data[groupName] = groupValue;
setTabSettings(data);
}
function toggleTab(tab, active) {
const tabPanelId = tab.getAttribute("aria-controls");
const tabPanel = document.getElementById(tabPanelId);
if (active) {
tab.classList.add("active");
tabPanel.classList.add("active");
} else {
tab.classList.remove("active");
tabPanel.classList.remove("active");
}
}
function toggleAll(selectedGroup, selectorsToSync) {
for (const [thisGroup, tabs] of Object.entries(selectorsToSync)) {
const active = selectedGroup === thisGroup;
for (const tab of tabs) {
toggleTab(tab, active);
}
}
}
function findSelectorsToSyncByLanguage() {
const result = {};
const tabs = Array.from(
document.querySelectorAll(`div[data-group] a[id^='tabset-']`)
);
for (const item of tabs) {
const div = item.parentElement.parentElement.parentElement;
const group = div.getAttribute("data-group");
if (!result[group]) {
result[group] = {};
}
const selectorsToSync = result[group];
const value = item.innerHTML;
if (!selectorsToSync[value]) {
selectorsToSync[value] = [];
}
selectorsToSync[value].push(item);
}
return result;
}
function setupSelectorSync() {
const selectorsToSync = findSelectorsToSyncByLanguage();
Object.entries(selectorsToSync).forEach(([group, tabSetsByValue]) => {
Object.entries(tabSetsByValue).forEach(([value, items]) => {
items.forEach((item) => {
item.addEventListener("click", (_event) => {
setTabState(group, value);
toggleAll(value, selectorsToSync[group]);
});
});
});
});
return selectorsToSync;
}
const selectorsToSync = setupSelectorSync();
for (const [group, selectedName] of Object.entries(getTabSettings())) {
const selectors = selectorsToSync[group];
// it's possible that stale state gives us empty selections, so we explicitly check here.
if (selectors) {
toggleAll(selectedName, selectors);
}
}
});
}

@ -0,0 +1 @@
.tippy-box[data-animation=fade][data-state=hidden]{opacity:0}[data-tippy-root]{max-width:calc(100vw - 10px)}.tippy-box{position:relative;background-color:#333;color:#fff;border-radius:4px;font-size:14px;line-height:1.4;white-space:normal;outline:0;transition-property:transform,visibility,opacity}.tippy-box[data-placement^=top]>.tippy-arrow{bottom:0}.tippy-box[data-placement^=top]>.tippy-arrow:before{bottom:-7px;left:0;border-width:8px 8px 0;border-top-color:initial;transform-origin:center top}.tippy-box[data-placement^=bottom]>.tippy-arrow{top:0}.tippy-box[data-placement^=bottom]>.tippy-arrow:before{top:-7px;left:0;border-width:0 8px 8px;border-bottom-color:initial;transform-origin:center bottom}.tippy-box[data-placement^=left]>.tippy-arrow{right:0}.tippy-box[data-placement^=left]>.tippy-arrow:before{border-width:8px 0 8px 8px;border-left-color:initial;right:-7px;transform-origin:center left}.tippy-box[data-placement^=right]>.tippy-arrow{left:0}.tippy-box[data-placement^=right]>.tippy-arrow:before{left:-7px;border-width:8px 8px 8px 0;border-right-color:initial;transform-origin:center right}.tippy-box[data-inertia][data-state=visible]{transition-timing-function:cubic-bezier(.54,1.5,.38,1.11)}.tippy-arrow{width:16px;height:16px;color:#333}.tippy-arrow:before{content:"";position:absolute;border-color:transparent;border-style:solid}.tippy-content{position:relative;padding:5px 9px;z-index:1}

File diff suppressed because one or more lines are too long
Loading…
Cancel
Save