# Sensitivität des Vergleichs

Diese Seite verzeichnet die beiden Monte-Carlo-Stabilitätstests hinter der [Vergleichskarte](/docs/comparison): die Achsengewichte über ihren ganzen Raum neu zu ziehen, bei eingefrorenen Teilwertungen, und jede Teilwertung bei den veröffentlichten Gewichten um bis zu einen Punkt zu stören. Sie hält die Designs und Seeds, die Rangintervalle für alle zweiundzwanzig Produkte, die Kreuzungsstatistiken der Direktvergleiche, eine zurückgezogene Behauptung und ein Skript, das die Akte reproduziert.

Die erste Fassung der [Methodikseite](/docs/comparison-methodology) prüfte
genau eine Alternativgewichtung und meldete eine Überkreuzung. Das Peer
Review tat, was wir hätten tun sollen: ein Monte Carlo über den ganzen
Gewichtsraum und über die Unsicherheit der Teilwertungen. Wir haben das
Design übernommen und es mit eigenem Code neu laufen lassen, statt die
Zahlen des Reviewers zu zitieren. Diese Seite veröffentlicht unsere; die
vollständige Akte der Läufe und der Abgleich mit den Zahlen des Reviewers
stehen in `review/index/SCORES.md`, und jede Teilwertung, auf die die Tests
zurückgreifen, steht auf der [Wertungsseite](/docs/comparison-scores).

## Die beiden Tests

Jeder Test läuft mit N = 200.000 Stichproben je Achse:

1. **Gewichte gezogen, Wertungen eingefroren.** Nicht-negative Gewichte,
   gleichverteilt über dem Gewichts-Simplex jeder Achse gezogen
   (normalisierte Exp(1)-Ziehungen); jede veröffentlichte Teilwertung fest
   gehalten. Das fragt: Wie viel von der Reihenfolge ist unser
   Gewichtungsurteil?
2. **Gewichte eingefroren, Wertungen gestört.** Die veröffentlichten
   Gewichte beibehalten; unabhängiges gleichverteiltes ±1-Rauschen auf jede
   Teilwertung jedes Produkts addiert, auf 0–10 beschnitten. Das fragt: Wie
   viel hängt daran, dass ganzzahlige Ermessensurteile exakt stimmen?

Ein Rang ist hier 1 + die Zahl der Produkte, die strikt höher bewertet sind.
Primärer Lauf: Seed 20260807. Unabhängige Neuimplementierung (anderer Code
und anderer Generator, Seed 271828). **Neu berechnet am 2026-08-07 über das
Feld von 22 Produkten, als Obscura bewertet wurde**: Rang ist relativ zum
Feld, die Intervalle unten wurden also mit Obscuras Zeile darin neu
gefahren, statt veraltet stehen zu bleiben, und die Arbeitsakte in
`review/index/SCORES.md` bewahrt die Läufe mit 21 Produkten als die Akte der
Reviewer-Reproduktion. **Am selben Tag erneut neu berechnet, als URnetwork
auf die Entscheidung „standardmäßig an“ hin neu bewertet wurde** (das
Methodik-Register): Die Tabellen unten sind die Akte nach der Neubewertung,
unter beiden Implementierungen und beiden Seeds neu gefahren, und die Läufe
vor der Neubewertung bleiben in der Läufe-Tabelle der Arbeitsakte. Auf dem
Feld von 22 Produkten stimmen die beiden Implementierungen in jedem
Intervall und jedem Median überein, außer in fünf Zellen des Gewichtstests
bei nahezu gleichauf liegenden Nachbarpaaren (0.05 auseinander in der
Punktwertung), vermerkt in der Arbeitsakte; dieselben fünf Zellen weichen
vor und nach der Neubewertung ab. Jedes Intervall aus dem
Wertungsrauschen — die Art, die die Überblicksseite veröffentlicht — ist
über beide Läufe identisch, ebenso wie URnetworks und Obscuras Zeilen in
Gänze.

## URnetworks Ergebnisse

Obscuras Eintritt bewegte die X-Rangstatistiken: Median der Gewichte 3→4,
Rauschintervall 2–3 → 2–4. Die Neubewertung auf „standardmäßig an“ bewegte
sie erneut: Median der Gewichte 4→3, Gewichtsintervall 2–5 → 1–4. Keine
Rangstatistik aus dem Rauschtest und keine Y-Statistik bewegte sich zu
einem der beiden Zeitpunkte.

| Statistik | Test 1, Gewichte gezogen | Test 2, ±1 Wertungsrauschen |
|---|---|---|
| X-Rang, Median (5.–95.) | 3 (1–4) | 3 (2–4) |
| X-Wertung, 5.–95. | (Gewichte variieren) | 6.793–7.813 |
| Vor Mullvad auf X | **74,0 %** (exakt 71/96) | **99,9 %** |
| Vor Obscura auf X | **100 %** (entartet; siehe unten) | **93,9 %** |
| Vor NymVPN auf X | **67,0 %** (exakt 343/512) | **30,6 %** |
| Y-Rang, Median (5.–95.) | 3 (1–15) | **7 (4–11)** |
| Y-Wertung, 5.–95. | (Gewichte variieren) | 5.747–6.653 |
| Y unter 5.00 | nie, durch Konstruktion (siehe die zurückgezogene Behauptung) | 0 von 200.000 (etwa 4,4σ unter diesem Rauschmodell: selten, nicht unmöglich) |

**Die beiden Achsen tragen nicht dieselbe Sicherheit.** URnetworks X-Rang
bleibt über jede gezogene Gewichtung innerhalb von 1–4: Nahe der Spitze der
X-Achse entscheidet das Urteil, das du fällst, über die Reihenfolge, aber
der Index selbst ist vergleichsweise gewichtsstabil. Sein Y-Rang läuft über
denselben Gewichtsraum von 1–15, und selbst bei den veröffentlichten
Gewichten ist der Median unter ±1-Rauschen der 7., nicht der geteilte 5.,
den seine Punktwertung nahelegt. Der Y-Index ist weit
gewichtsempfindlicher als X, und jede Y-Platzierung auf der Karte sollte
mit entsprechend weniger Sicherheit gelesen werden.

## Die Direktvergleiche

**Mullvad.** Die beiden Mullvad-Zahlen bleiben der aussagekräftigste Satz,
den es über dieses Diagramm gibt, und die Neubewertung auf „standardmäßig
an“ hat geändert, was sie sagen. Davor lag URnetworks X-Vorsprung vor
Mullvad unter Gewichtsunsicherheit bei 55,55 %, ein Münzwurf, der fast
vollständig davon abhing, Struktur (Trennung plus Ende-zu-Ende, zusammen
0.60) über Verifikation (0.30) zu stellen. Nach der Neubewertung ist die
Differenz der Teilwertungen (+5, +3, −3, −1): URnetwork führt bei beiden
Struktur-Komponenten, Mullvad führt bei der Verifikation und, knapp, bei
Post-Quanten, und dieselbe Gewichtsziehung gibt URnetwork in 74,0 % der
Fälle den Vorsprung (das exakte Simplex-Volumen ist 71/96 ≈ 73,96 %). Unter
±1 Wertungsrauschen bei den veröffentlichten Gewichten sind es 99,9 %. Der
Vorsprung ist kein Münzwurf mehr, aber die restliche
Gewichtsempfindlichkeit überlebt in eingegrenzter Form: Wer die Verifikation
mit etwa der halben Achse gewichtet, bekommt weiterhin Mullvad vorn, und das
bleibt eine vernünftige Lesart, kein Fehler. Das durchgerechnete Beispiel
steht unten im Abschnitt zur Neugewichtung.

**Obscura.** Der Vergleich hat sich mit der Neubewertung umgedreht. Die
beiden stehen jetzt bei der Struktur gleich (E+S 16–16, beide inhaltlichen
Zweige standardmäßig an, Obscuras durch Konstruktion) und bei V gleich, die
gesamte Lücke von 0.70 ist also die Post-Quanten-Komponente, die Obscura
fehlt. Eine reine Differenz in einer einzigen Komponente, (0, 0, 0, +7),
macht den Gewichtstest entartet: Jede Gewichtung mit einem
Post-Quanten-Gewicht ungleich null erhält die Reihenfolge, „100 % der
gezogenen Gewichtungen“ beschreibt also die Form des Unterschieds, nicht
seine Robustheit. Der aussagekräftige Test ist das Wertungsrauschen, unter
dem URnetwork in 93,9 % der Fälle führt: außerhalb der Reichweite eines
einzelnen Punkts, denn der größte Ausschlag eines Punktes in einer
Komponente ist 0.35 gegen eine Lücke von 0.70. Die Statistik vor der
Neubewertung war 69,5 %, was nicht außerhalb dieser Reichweite lag; bei den
Wertungen vor der Neubewertung war die Differenz (−1, 0, 0, +5) mit exaktem
Simplex-Anteil 5/6 = 83,33 % (gezogen 83,4 %). Was das Paar wirklich
schließen würde, wäre Obscura, das Post-Quanten ausliefert.

**NymVPN.** Die Neubewertung hat eine neue Nachbarschaft in der anderen
Richtung eingekauft: NymVPN liegt bei 7.50 jetzt 0.20 über URnetworks 7.30,
näher, als Obscura darunter liegt. Bei den veröffentlichten Gewichten führt
NymVPN. Über die gezogenen Gewichtungen führt URnetwork zu 67,0 %: Die
Differenz ist (−1, −1, −1, +7), die Reihenfolge hängt also ganz daran, ob
das Post-Quanten-Gewicht ein Achtel übersteigt, und der exakte Anteil ist
(7/8)³ = 343/512. Unter Wertungsrauschen hält NymVPN seinen Vorsprung in
69,4 % der Ziehungen (URnetwork vorn zu 30,6 %). Dieses Paar steht als
benachbart und unentschieden gedruckt, genau wie Obscura und URnetwork es
vor der Neubewertung waren.

## Rangintervalle pro Produkt

5.–95. Perzentil mit dem Median in Klammern, Feld von 22 Produkten nach der
Neubewertung, Lauf der Neuimplementierung, Seed 271828. URnetworks und
Obscuras Zeilen sind im primären Lauf identisch. Die Neubewertung änderte
sechs Zellen, alle in der Spalte X-Gewichte: URnetwork 2–5 (4) → 1–4 (3),
Tor 1–4 → 1–5, Obscura 3–10 → 4–10, Mullvad Median 3 → 4, IVPN 2–8 → 3–8,
ExpressVPN 2–12 → 3–12.

| Produkt | X-Rang, Gewichte | X-Rang, Wertungsrauschen | Y-Rang, Gewichte | Y-Rang, Wertungsrauschen |
|---|---|---|---|---|
| Tor | 1–5 (1) | 1–1 (1) | 2–22 (19) | 21–22 (22) |
| NymVPN | 2–9 (3) | 2–3 (2) | 18–21 (20) | 20–21 (20) |
| URnetwork | 1–4 (3) | 2–4 (3) | 1–15 (3) | 4–11 (7) |
| Obscura | 4–10 (6) | 3–5 (4) | 9–19 (15) | 12–19 (16) |
| Mullvad | 1–7 (4) | 4–6 (5) | 4–12 (7) | 4–11 (7) |
| IVPN | 3–8 (5) | 5–7 (6) | 6–16 (11) | 6–13 (10) |
| Apple Private Relay | 5–15 (9) | 6–8 (7) | 13–22 (20) | 13–19 (17) |
| Windscribe | 7–14 (11) | 7–10 (8) | 1–5 (2) | 1–5 (3) |
| PIA | 7–15 (12) | 8–13 (10) | 9–15 (11) | 7–14 (11) |
| ExpressVPN | 3–12 (8) | 8–13 (10) | 4–10 (6) | 4–11 (7) |
| Proton VPN | 9–16 (14) | 9–15 (12) | 1–5 (3) | 1–6 (3) |
| NordVPN | 6–13 (10) | 9–15 (12) | 4–10 (6) | 4–11 (7) |
| Tailscale | 6–18 (13) | 10–16 (13) | 1–8 (3) | 1–3 (1) |
| Cloudflare WARP | 7–16 (11) | 11–16 (14) | 1–16 (5) | 1–6 (3) |
| Surfshark | 7–16 (12) | 11–17 (15) | 8–13 (9) | 5–12 (8) |
| Orchid | 9–17 (16) | 12–17 (15) | 19–22 (21) | 20–22 (21) |
| Mysterium | 16–18 (18) | 16–20 (18) | 7–20 (18) | 14–19 (17) |
| TunnelBear | 15–19 (17) | 16–20 (18) | 8–16 (13) | 9–16 (13) |
| Sentinel | 17–20 (20) | 17–20 (19) | 9–19 (16) | 15–19 (18) |
| IPVanish | 19–20 (19) | 17–21 (19) | 12–19 (16) | 9–16 (13) |
| Hotspot Shield | 21–21 (21) | 20–21 (21) | 8–18 (12) | 9–16 (13) |
| Hola | 22–22 (22) | 22–22 (22) | 6–20 (17) | 14–19 (17) |

Die Intervalle übertreiben nichts aus einem Zufall der Symmetrie der
Modelle. Das ±1-Rauschmodell ist bewusst einfach und ist keine empirische
Fehlerverteilung; seine Lehre ist, dass Rangsicherheit Unsicherheit in den
vergebenen Wertungen verlangt, nicht nur einen zweiten Satz Gewichte.

## Haben wir den Reviewer reproduziert?

Ja, auf dem Feld von 21 Produkten, das der Reviewer vermessen hat, bevor
Obscura bewertet wurde. Der Reviewer berichtete (Seed 20260807, 200.000
Stichproben, eigener Code): 55,64 % vor Mullvad; X-Wertung 6.341–7.359 und
Y-Wertung 5.749–6.652 unter Rauschen; Rangintervalle 2–5, 1–15, 2–3 und
4–11 bei Y-Median 7. Unsere Läufe auf diesem Feld gaben dieselben
Rangintervalle und Mediane zurück, Wertungsgrenzen innerhalb von 0.005 und
55,55 % bei der Überkreuzung; die Akte mit 21 Produkten ist in
`review/index/SCORES.md` erhalten, und die paarweisen Mullvad-Statistiken
tragen sich innerhalb des Stichprobenfehlers unverändert in den erneuten
Lauf mit 22 Produkten hinüber. Die Überkreuzung hatte eine exakte Antwort,
gegen die sich alle prüfen ließen: Bei den Wertungen, die der Reviewer
vermessen hat, war die Differenz der Teilwertungen URnetwork−Mullvad
(+4, +3, −3, −3), und der Anteil des Gewichts-Simplex auf URnetworks Seite
ergibt 109/196 ≈ 55,61 %. Jede Monte-Carlo-Schätzung, unsere wie die des
Reviewers, liegt innerhalb des Stichprobenfehlers (±0.22 Punkte bei 2σ für
diese Stichprobengröße) am exakten Wert. Ein Ergebnis von außen, das wir
nicht reproduzieren könnten, wäre ein Befund gewesen; eines, das wir
reproduzieren können, ist es auch. Die Zahlen des Reviewers sind die Akte
vor der Neubewertung: URnetworks Neubewertung auf „standardmäßig an“ später
am selben Tag änderte seine X-Zeile, und der aktuelle exakte Wert der
Überkreuzung ist 71/96, hergeleitet und gezogen in den Tabellen oben. Die
Prüfung mit 109/196 reproduziert weiterhin auf den Wertungen vor der
Neubewertung, und genau die hat der Reviewer vermessen.

## Eine zurückgezogene Behauptung, verzeichnet

Bis 2026-08-07 behauptete die Methodikseite, URnetworks Y-Platzierung sei
gewichtsrobust: dass keine Neugewichtung der Qualitäts-Komponenten sein Y
unter 5.00 drücken könne. Das Urteil des Reviewers, **„mathematisch wahr,
aber ohne Beleggehalt“**, wird vollständig angenommen. Jede Y-Teilwertung
von URnetwork wurde mit ≥ 5 vergeben, jedes konvexe gewichtete Mittel aus
ihnen ist also **durch Konstruktion** ≥ 5: Wir haben eine Eigenschaft
unserer eigenen Eingaben veröffentlicht, als wäre sie ein Beleg für
Stabilität, und die Behauptung wäre „wahr“ geblieben, wie falsch diese
Eingaben auch gewesen wären. Derselbe Einwand erledigt die entsprechende
Behauptung zur X-Achse, bevor sie jemand aufstellt. URnetworks
X-Teilwertungen sind ebenfalls alle ≥ 5, „keine Neugewichtung bringt X unter
5.00“ ist also genauso garantiert und genauso leer. Keine der beiden
Aussagen erscheint in diesem Korpus je wieder als Beleg. Was sich aus der
Akte sagen lässt, steht in den Tabellen oben: Unter Neugewichtung ist die
aussagekräftige Ausgabe der Rang, nicht das Überqueren einer Linie, und der
aussagekräftige Test der Linien ist die Wertungsunsicherheit, unter der
URnetworks Y im 5. Perzentil bei 5.747 liegt — eine Aussage über ein
einfaches Rauschmodell, nicht über die Welt. Was Y wirklich unter die Linie
bewegen könnte, sind Belege: eine Messung von außen, die Latenz oder
Durchsatz unter die Mitte der Skala bewertet. Es existiert keine, in keine
Richtung, und die Regel des Korpus, keine Zahlen zu erfinden, ist der Grund,
warum die beiden Leistungs-Komponenten auf dem Anker der strukturellen
Klasse bei 5 sitzen. Die Behauptung wird zurückgezogen statt gelöscht, weil
der Wert dieser Akte darin liegt, dass sie ihre eigenen Fehler behält.

## Die Platzierungen im Rauschen

Sensitivität gegenüber einem einzelnen Punkt ist kein Monte Carlo, aber sie
beantwortet dieselbe Frage an den Rändern, also steht sie hier bei allem
Übrigen.

- **Apple Private Relay** sitzt zugleich im Rauschen beider Linien, +0.10
  auf X und −0.05 auf Y, allein am Kreuzungspunkt der Achsen, es sollte
  also als auf den Linien gelesen werden, nicht als Bewohner irgendeines
  Quadranten, auch nicht der Zelle Akademisch / Forschung, in die seine
  strengen Vorzeichen fallen. Seine Ermessensurteile schneiden in beide
  Richtungen: E oder S einen Punkt niedriger zu bewerten setzt X auf 4.85
  oder 4.75, zurück links der Linie, und S mit 8 zu bewerten setzt es klar
  rechts auf 5.45. Diese Empfindlichkeit ist der Grund, warum die Karte es
  annotiert, statt es zu beanspruchen.
- **Obscura** sitzt von oben im Rauschen der Y-Linie (+0.10). Seine
  Zugehörigkeit rechts der X-Linie ist bei +1.60 sicher und rauschrobust:
  0 von 200.000 ±1-Rausch-Stichproben setzen es links der Linie, bei einem
  X im 5. Perzentil von 6.12. Gewichtsempfindlich ist es allerdings:
  32,6 % der gleichverteilt gezogenen Gewichtungen setzen es nach links,
  getrieben von PQ 0 und V 6, wenn die Gewichte für Verifikation und
  Post-Quanten hoch gezogen werden. Die Y-Seite ist ein echter Münzwurf und
  wird für nichts beansprucht: Y fällt in 36 % der ±1-Rausch-Stichproben
  und in 50,0 % der gezogenen Gewichtungen unter 5.00, und die einzige als
  Ermessensurteil ausgewiesene T-Komponente bewegt es auf beide Seiten
  (T 5 ergibt 4.85, T 7 ergibt 5.35).
- **Mysterium** (−0.10), **Hola** (−0.15) und **Sentinel** (−0.20) sitzen
  im Rauschen unter der Y-Linie, und ihre Zugehörigkeit darüber oder
  darunter ist kein sicheres Urteil; ihre X-Defizite sind das Signal.
- Von oben nehmen **TunnelBear**, **IPVanish** und **Hotspot Shield** die
  Y-Linie nur um 0.45–0.50, auf L- und T-Wertungen der
  Rechenzentrums-Klasse; wer ihre Flottenkonsistenz einen Punkt niedriger
  bewertete, setzte sie auf sie oder darunter.
- Die X-Linie ist für alle anderen klares Wasser. Außer Apple kommt ihr
  nichts näher als 0.40 (Windscribe von links; IVPN bei 0.70 und Obscura
  bei 1.60 von rechts), keine Neubewertung um einen einzelnen Punkt bewegt
  also irgendein anderes Produkt über die Trennung
  verifizierbar/vertrauensbasiert. Apple ist das eine Produkt, dessen Seite
  dieser Linie legitim an einem einzigen Ermessensurteil hängt.

## Neugewichtungen, die die Reihenfolge ändern

Die festen Gewichte stellen Struktur (S + E = 0.60) über Verifikation
(V = 0.30). Gewichte hart genug in Richtung Verifikation um, zum Beispiel
E 0.15 / S 0.25 / V 0.50 / PQ 0.10, und **Mullvad (7.00) zieht an URnetwork
(6.90) vorbei** auf der Achse; treib das Verifikationsgewicht noch höher,
und irgendwann zieht auch IVPN vorbei. Vor der Neubewertung auf
„standardmäßig an“ reichte ein milderes Beispiel (V bei 0.45), und die Zahl
über den ganzen Raum war ein Münzwurf: Über gleichverteilt gezogene
Gewichtungen führte URnetwork damals nur in 55,55 % der Fälle vor Mullvad.
Nach der Neubewertung führt es zu 74,0 %, und das Beispiel mit V 0.45 dreht
die Reihenfolge nicht mehr um (URnetwork 7.00, Mullvad 6.70). Die
Reihenfolge an der Spitze der Achse ruht jetzt auf den
Struktur-Teilwertungen ebenso wie auf den Gewichten, aber wer Audits und
Gerichtsbelege mit etwa der halben Achse gewichtet, bekommt weiterhin
Mullvad vorn und liegt nicht falsch. Die Seiten pro Produkt sagen dieser
Leserin schon heute, sie solle Mullvad oder IVPN nehmen, bis URnetwork seine
Lücke beim Protokoll-Audit schließt.

Derselbe Hebel in die andere Richtung gezogen ist das durchgerechnete
IVPN-Beispiel: Ein struktur-dominantes E 0.35 / S 0.45 / V 0.10 / PQ 0.10
drückt IVPN auf 4.90, knapp links der Linie, mit Mullvad bei exakt 5.00 auf
ihr. IVPNs Zugehörigkeit zum Quadranten oben rechts hängt davon ab, dass das
Verifikationsgewicht substanziell bleibt. Die eingefrorenen Gewichte
schaffen das, und die Abhängigkeit ist hier trotzdem verzeichnet.

Auf der Qualitätsachse ist Eg bei 0.20 das, was URnetworks Abstand hebt und
WARP, Private Relay und Tor unten hält. Wer Ausgangs-Identität als Nische
behandelt, staucht die Y-Streuung, und URnetworks Y-Rang läuft über den
gezogenen Gewichtsraum vom 1. bis zum 15. Eine frühere Fassung der
Methodikseite schloss diese Analyse mit der Behauptung, keine Neugewichtung
könne URnetworks Y unter 5.00 drücken; diese Behauptung ist zurückgezogen,
und der Abschnitt oben verzeichnet, warum.

## Was diese Akte bewegen würde

Drei Dinge würden die Ergebnisse auf dieser Seite bewegen, statt mit ihnen
zu streiten. Obscura, das Post-Quanten ausliefert, würde das eine entartete
Paar schließen (die Direktvergleiche oben). Eine Messung von Latenz oder
Durchsatz von außen würde die strukturellen Anker ersetzen, die der
Abschnitt zur zurückgezogenen Behauptung als weichen Grund der
Qualitätsachse benennt. Und jede Neubewertung unter der stehenden Regel der
[Methodikseite](/docs/comparison-methodology) fährt beide Tests über das
ganze Feld erneut, so wie es die Aufnahme von Obscura und die Neubewertung
auf „standardmäßig an“ getan haben.

## Reproduktionsskript

Die Implementierung für den Gegencheck, auf dem Feld von 22 Produkten mit
URnetworks Zeile nach der Neubewertung. Sie erzeugt ihre Zeile der
Läufe-Tabelle in `review/index/SCORES.md`, URnetworks Rangstatistiken und
die Tabelle pro Produkt oben neu (Python 3, numpy ≥ 2). Sie prüft per
Assertion, dass sich jede veröffentlichte X- und Y-Summe vor dem Sampling
exakt aus den Matrizen der Teilwertungen nachrechnen lässt, und die
Reihenfolge der Ziehungen zählt für exakte Reproduktion.

```python
import numpy as np

SEED, N = 271828, 200_000

# name, X parts [X1 e2e, X2 sep, X3 verif, X4 pq], Y parts [Y1 L, Y2 T, Y3 Eg, Y4 In, Y5 A]
# URnetwork X row is the post-rescore [8, 8, 6, 7]; pre-rescore was [7, 8, 6, 5]
PRODUCTS = [
    ("URnetwork",           [8, 8, 6, 7],    [5, 5, 8, 8, 7]),
    ("Tor",                 [10, 10, 10, 4], [2, 1, 1, 9, 9]),
    ("NymVPN",              [9, 9, 7, 0],    [5, 4, 2, 5, 4]),
    ("Obscura",             [8, 8, 6, 0],    [5, 6, 5, 5, 4]),
    ("Mullvad",             [3, 5, 9, 8],    [7, 8, 5, 5, 4]),
    ("IVPN",                [3, 5, 8, 8],    [7, 7, 5, 4, 4]),
    ("Apple Private Relay", [7, 7, 3, 0],    [8, 7, 2, 1, 2]),
    ("Windscribe",          [3, 5, 7, 0],    [7, 7, 6, 7, 7]),
    ("PIA",                 [3, 3, 8, 0],    [7, 7, 4, 5, 4]),
    ("ExpressVPN",          [3, 3, 5, 9],    [7, 8, 4, 6, 4]),
    ("Proton VPN",          [3, 3, 7, 0],    [7, 8, 4, 6, 8]),
    ("NordVPN",             [3, 3, 5, 6],    [7, 8, 4, 6, 4]),
    ("Tailscale",           [6, 2, 5, 0],    [9, 9, 5, 4, 6]),
    ("Cloudflare WARP",     [3, 4, 3, 5],    [9, 8, 2, 4, 8]),
    ("Surfshark",           [3, 3, 4, 5],    [7, 8, 4, 5, 4]),
    ("Orchid",              [3, 5, 3, 0],    [6, 3, 3, 3, 3]),
    ("Mysterium",           [3, 3, 3, 0],    [6, 4, 6, 3, 4]),
    ("TunnelBear",          [3, 2, 4, 0],    [7, 6, 3, 5, 5]),
    ("Sentinel",            [3, 3, 2, 0],    [6, 4, 4, 6, 4]),
    ("IPVanish",            [3, 2, 3, 0],    [7, 7, 3, 4, 4]),
    ("Hotspot Shield",      [3, 1, 2, 0],    [7, 7, 2, 5, 5]),
    ("Hola",                [1, 0, 0, 0],    [5, 4, 5, 3, 7]),
]
WX = np.array([0.25, 0.35, 0.30, 0.10])
WY = np.array([0.30, 0.25, 0.20, 0.10, 0.15])
names = [p[0] for p in PRODUCTS]
XS = np.array([p[1] for p in PRODUCTS], float)
YS = np.array([p[2] for p in PRODUCTS], float)
UR, MV = names.index("URnetwork"), names.index("Mullvad")
OB, NY = names.index("Obscura"), names.index("NymVPN")

# sanity: every published total must recompute exactly (SCORES.md tables, product order above)
EXP_X = [7.30, 9.40, 7.50, 6.60, 6.00, 5.70, 5.10, 4.60, 4.20, 4.20, 3.90,
         3.90, 3.70, 3.55, 3.50, 3.40, 2.70, 2.65, 2.40, 2.35, 1.70, 0.25]
EXP_Y = [6.20, 3.30, 4.00, 5.10, 6.20, 5.85, 4.95, 6.80, 5.75, 6.10, 6.70,
         6.10, 7.25, 6.70, 6.00, 3.90, 4.90, 5.45, 4.80, 5.45, 5.50, 4.85]
assert np.allclose(XS @ WX, EXP_X) and np.allclose(YS @ WY, EXP_Y)

def ranks(scores):  # rank = 1 + #{strictly greater}
    return 1 + (scores[:, None, :] > scores[:, :, None]).sum(axis=2)

def q(a, p):
    return np.quantile(a, p, method="nearest")

def sim_A(rng, subs, w, chunk=50_000):
    R, S = [], []
    for start in range(0, N, chunk):
        m = min(chunk, N - start)
        sc = rng.dirichlet(np.ones(len(w)), size=m) @ subs.T
        R.append(ranks(sc)); S.append(sc)
    return np.vstack(R), np.vstack(S)

def sim_B(rng, subs, w, chunk=20_000):
    R, S = [], []
    for start in range(0, N, chunk):
        m = min(chunk, N - start)
        noisy = np.clip(subs[None] + rng.uniform(-1, 1, (m,) + subs.shape), 0, 10)
        sc = noisy @ w
        R.append(ranks(sc)); S.append(sc)
    return np.vstack(R), np.vstack(S)

rng = np.random.default_rng(SEED)   # draw order matters for exact reproduction:
rAX, sAX = sim_A(rng, XS, WX)       # 1) test 1, X axis
rAY, sAY = sim_A(rng, YS, WY)       # 2) test 1, Y axis
rBX, sBX = sim_B(rng, XS, WX)       # 3) test 2, X axis
rBY, sBY = sim_B(rng, YS, WY)       # 4) test 2, Y axis

print("exact P(UR>MV | test 1) = 71/96 =", 71 / 96)   # pre-rescore: 109/196
print("test 1: P(UR>MV on X) =", (sAX[:, UR] > sAX[:, MV]).mean())
print("test 2: P(UR>MV on X) =", (sBX[:, UR] > sBX[:, MV]).mean())
print("exact P(UR>Obscura | test 1) = 1 (pure X4 difference; pre-rescore: 5/6)")
print("test 1: P(UR>Obscura on X) =", (sAX[:, UR] > sAX[:, OB]).mean())
print("test 2: P(UR>Obscura on X) =", (sBX[:, UR] > sBX[:, OB]).mean())
print("exact P(UR>NymVPN | test 1) = 343/512 =", 343 / 512)
print("test 1: P(UR>NymVPN on X) =", (sAX[:, UR] > sAX[:, NY]).mean())
print("test 2: P(UR>NymVPN on X) =", (sBX[:, UR] > sBX[:, NY]).mean())
print("test 2: UR X score 5-95%:", q(sBX[:, UR], 0.05), q(sBX[:, UR], 0.95))
print("test 2: UR Y score 5-95%:", q(sBY[:, UR], 0.05), q(sBY[:, UR], 0.95))
print("test 2: UR Y < 5.00 in", int((sBY[:, UR] < 5).sum()), "of", N)
for label, r in (("AX", rAX), ("BX", rBX), ("AY", rAY), ("BY", rBY)):
    print(label, "UR rank p5/med/p95:", int(q(r[:, UR], .05)), int(q(r[:, UR], .5)), int(q(r[:, UR], .95)))
print("| Product | X rank, weights | X rank, score noise | Y rank, weights | Y rank, score noise |")
print("|---|---|---|---|---|")
f = lambda r, j: f"{int(q(r[:, j], .05))}–{int(q(r[:, j], .95))} ({int(q(r[:, j], .5))})"
for j in np.argsort(-(XS @ WX), kind="stable"):
    print(f"| {names[j]} | {f(rAX, j)} | {f(rBX, j)} | {f(rAY, j)} | {f(rBY, j)} |")
```
