Comparison map

Sensitivität des Vergleichs

13 Min. LesezeitView as markdown ↗

Diese Seite verzeichnet die beiden Monte-Carlo-Stabilitätstests hinter der Vergleichskarte: 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 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.

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.

StatistikTest 1, Gewichte gezogenTest 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 X74,0 % (exakt 71/96)99,9 %
Vor Obscura auf X100 % (entartet; siehe unten)93,9 %
Vor NymVPN auf X67,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.00nie, 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.

ProduktX-Rang, GewichteX-Rang, WertungsrauschenY-Rang, GewichteY-Rang, Wertungsrauschen
Tor1–5 (1)1–1 (1)2–22 (19)21–22 (22)
NymVPN2–9 (3)2–3 (2)18–21 (20)20–21 (20)
URnetwork1–4 (3)2–4 (3)1–15 (3)4–11 (7)
Obscura4–10 (6)3–5 (4)9–19 (15)12–19 (16)
Mullvad1–7 (4)4–6 (5)4–12 (7)4–11 (7)
IVPN3–8 (5)5–7 (6)6–16 (11)6–13 (10)
Apple Private Relay5–15 (9)6–8 (7)13–22 (20)13–19 (17)
Windscribe7–14 (11)7–10 (8)1–5 (2)1–5 (3)
PIA7–15 (12)8–13 (10)9–15 (11)7–14 (11)
ExpressVPN3–12 (8)8–13 (10)4–10 (6)4–11 (7)
Proton VPN9–16 (14)9–15 (12)1–5 (3)1–6 (3)
NordVPN6–13 (10)9–15 (12)4–10 (6)4–11 (7)
Tailscale6–18 (13)10–16 (13)1–8 (3)1–3 (1)
Cloudflare WARP7–16 (11)11–16 (14)1–16 (5)1–6 (3)
Surfshark7–16 (12)11–17 (15)8–13 (9)5–12 (8)
Orchid9–17 (16)12–17 (15)19–22 (21)20–22 (21)
Mysterium16–18 (18)16–20 (18)7–20 (18)14–19 (17)
TunnelBear15–19 (17)16–20 (18)8–16 (13)9–16 (13)
Sentinel17–20 (20)17–20 (19)9–19 (16)15–19 (18)
IPVanish19–20 (19)17–21 (19)12–19 (16)9–16 (13)
Hotspot Shield21–21 (21)20–21 (21)8–18 (12)9–16 (13)
Hola22–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 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.

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)} |")