# Sensibilidad de la comparativa

Esta página registra las dos pruebas de estabilidad Monte Carlo que hay detrás del [mapa de comparativas](/docs/comparison): remuestrear los pesos de los ejes por todo su espacio con las subpuntuaciones congeladas, y perturbar cada subpuntuación hasta en un punto con los pesos publicados. Contiene los diseños y las semillas, los intervalos de rango de los veintidós productos, las estadísticas de cruce en las comparaciones directas, una afirmación retirada, y un script que reproduce el registro.

La primera versión de la [página de metodología](/docs/comparison-methodology)
probó exactamente una ponderación alternativa e informó de un cruce. La
revisión por pares hizo lo que deberíamos haber hecho nosotros: un Monte
Carlo por todo el espacio de pesos y por la incertidumbre de las
subpuntuaciones. Adoptamos el diseño y lo volvimos a ejecutar con nuestro
propio código en lugar de citar los números del revisor. Esta página publica
los nuestros; el registro completo de ejecuciones y la comprobación de
concordancia contra las cifras del revisor está en
`review/index/SCORES.md`, y cada subpuntuación de la que se nutren las
pruebas está en la [página de puntuaciones](/docs/comparison-scores).

## Las dos pruebas

Cada prueba ejecuta N = 200,000 muestras por eje:

1. **Pesos muestreados, puntuaciones congeladas.** Pesos no negativos
   extraídos uniformemente sobre el símplex de pesos de cada eje
   (extracciones Exp(1) normalizadas); cada subpuntuación publicada
   mantenida fija. Esto pregunta: ¿cuánto del orden es nuestro juicio de
   ponderación?
2. **Pesos congelados, puntuaciones perturbadas.** Se mantienen los pesos
   publicados; se añade ruido uniforme independiente de ±1 a cada
   subpuntuación de cada producto, recortado a 0–10. Esto pregunta: ¿cuánto
   depende de que los juicios enteros sean exactamente correctos?

Un rango aquí es 1 + el número de productos que puntúan estrictamente más
alto. Ejecución primaria: semilla 20260807. Reimplementación independiente
(código y generador distintos, semilla 271828). **Recomputado el 2026-08-07
sobre el campo de 22 productos cuando se puntuó Obscura**: el rango es
relativo al campo, así que los intervalos de abajo se volvieron a ejecutar
con la fila de Obscura dentro en lugar de dejarse caducos, y el registro de
trabajo en `review/index/SCORES.md` conserva las ejecuciones de 21 productos
como el registro de reproducción del revisor. **Recomputado de nuevo el
mismo día cuando URnetwork se repuntuó por el dictamen de activado por
defecto** (el libro de la metodología): las tablas de abajo son el registro
posterior a la repuntuación, vueltas a ejecutar bajo ambas implementaciones
y ambas semillas, y las ejecuciones previas a la repuntuación se quedan en
la tabla de ejecuciones del registro de trabajo. Sobre el campo de 22
productos las dos implementaciones coinciden en todos los intervalos y
medianas salvo en cinco celdas de la prueba de pesos en pares adyacentes
casi empatados (a 0.05 de distancia en puntuación puntual), anotadas en el
registro de trabajo; esas mismas cinco celdas difieren antes y después de la
repuntuación. Todos los intervalos de ruido de puntuación, del tipo que
publica la página de visión general, son idénticos en ambas ejecuciones,
igual que las filas de URnetwork y de Obscura al completo.

## Los resultados de URnetwork

La entrada de Obscura movió las estadísticas de rango en X: mediana de pesos
3→4, intervalo de ruido 2–3 → 2–4. La repuntuación por activado por defecto
las movió otra vez: mediana de pesos 4→3, intervalo de pesos 2–5 → 1–4.
Ninguna estadística de rango de la prueba de ruido ni ninguna estadística de
Y se movió en ninguna de las dos ocasiones.

| Estadística | Prueba 1, pesos muestreados | Prueba 2, ruido de puntuación de ±1 |
|---|---|---|
| Rango X, mediana (5.º–95.º) | 3 (1–4) | 3 (2–4) |
| Puntuación X, 5.º–95.º | (los pesos varían) | 6.793–7.813 |
| Por delante de Mullvad en X | **74.0%** (exactamente 71/96) | **99.9%** |
| Por delante de Obscura en X | **100%** (degenerado; ver más abajo) | **93.9%** |
| Por delante de NymVPN en X | **67.0%** (exactamente 343/512) | **30.6%** |
| Rango Y, mediana (5.º–95.º) | 3 (1–15) | **7 (4–11)** |
| Puntuación Y, 5.º–95.º | (los pesos varían) | 5.747–6.653 |
| Y por debajo de 5.00 | nunca, por construcción (ver la afirmación retirada) | 0 de 200,000 (unos 4.4σ bajo este modelo de ruido: raro, no imposible) |

**Los dos ejes no llevan la misma confianza.** El rango X de URnetwork se
mantiene dentro de 1–4 en todas las ponderaciones muestreadas: cerca de la
parte alta del eje X, el juicio que hagas decide el orden, pero el índice en
sí es comparativamente estable ante los pesos. Su rango Y va de 1 a 15 en
ese mismo espacio de pesos, e incluso con los pesos publicados la mediana
bajo ruido de ±1 es 7.ª, no la 5.ª empatada que sugiere su puntuación
puntual. El índice Y es mucho más sensible a los pesos que el X, y toda
colocación en Y del mapa debería leerse con la correspondiente menor
confianza.

## Las comparaciones directas

**Mullvad.** Los dos números de Mullvad siguen siendo la frase más
informativa disponible sobre este gráfico, y la repuntuación por activado
por defecto cambió lo que dicen. Antes de ella, la ventaja en X de URnetwork
sobre Mullvad era del 55.55% bajo incertidumbre de pesos, un cara o cruz que
dependía casi enteramente de clasificar la estructura (separación más
extremo a extremo, 0.60 combinados) por encima de la verificación (0.30).
Tras la repuntuación la diferencia de subpuntuaciones es (+5, +3, −3, −1):
URnetwork va por delante en ambas partes de estructura, Mullvad va por
delante en verificación y, por poco, en lo poscuántico, y el mismo muestreo
de pesos da la ventaja a URnetwork el 74.0% de las veces (el volumen exacto
del símplex es 71/96 ≈ 73.96%). Bajo ruido de puntuación de ±1 con los pesos
publicados es del 99.9%. La ventaja ya no es un cara o cruz, pero la
sensibilidad residual a los pesos sobrevive de forma acotada: un lector que
pondere la verificación en torno a la mitad del eje sigue obteniendo a
Mullvad por delante, y eso sigue siendo una lectura razonable, no un error.
El ejemplo desarrollado está en la sección de reponderación de más abajo.

**Obscura.** La comparación se invirtió con la repuntuación. Las dos empatan
ahora en estructura (E+S 16–16, ambos tramos de contenido activados por
defecto, el de Obscura por construcción) y empatan en V, así que toda la
distancia de 0.70 es la parte poscuántica que le falta a Obscura. Una
diferencia pura de una sola parte, (0, 0, 0, +7), vuelve degenerada la
prueba de pesos: cualquier ponderación con un peso poscuántico no nulo
preserva el orden, así que el "100% de las ponderaciones muestreadas"
describe la forma de la diferencia, no su robustez. La prueba informativa es
el ruido de puntuación, bajo el cual URnetwork va por delante el 93.9% de
las veces: fuera del alcance de un solo punto, ya que el mayor vaivén de un
punto en una parte es de 0.35 frente a una distancia de 0.70. La estadística
previa a la repuntuación era del 69.5%, que no estaba fuera de ese alcance;
con las puntuaciones previas a la repuntuación la diferencia era
(−1, 0, 0, +5) con una cuota exacta del símplex de 5/6 = 83.33% (muestreada
83.4%). Lo que de verdad cerraría el par sería que Obscura incorporase lo
poscuántico.

**NymVPN.** La repuntuación compró una nueva adyacencia en la otra
dirección: NymVPN, con 7.50, se sitúa ahora 0.20 por encima del 7.30 de
URnetwork, más cerca de lo que Obscura está por debajo. Con los pesos
publicados NymVPN va por delante. A lo largo de las ponderaciones
muestreadas URnetwork va por delante el 67.0%: la diferencia es
(−1, −1, −1, +7), así que el orden depende enteramente de si el peso
poscuántico supera un octavo, y la cuota exacta es (7/8)³ = 343/512. Bajo
ruido de puntuación NymVPN conserva su ventaja en el 69.4% de las
extracciones (URnetwork por delante el 30.6%). Ese par se imprime como
adyacente y no asentado, exactamente como estaban Obscura y URnetwork antes
de la repuntuación.

## Intervalos de rango por producto

Percentil 5.º–95.º con la mediana entre paréntesis, campo de 22 productos
posterior a la repuntuación, ejecución de la reimplementación, con la
semilla 271828. Las filas de URnetwork y de Obscura son idénticas bajo la
ejecución primaria. La repuntuación cambió seis celdas, todas en la columna
de pesos
X: URnetwork 2–5 (4) → 1–4 (3), Tor 1–4 → 1–5, Obscura 3–10 → 4–10, la
mediana de Mullvad 3 → 4, IVPN 2–8 → 3–8, ExpressVPN 2–12 → 3–12.

| Producto | Rango X, pesos | Rango X, ruido de puntuación | Rango Y, pesos | Rango Y, ruido de puntuación |
|---|---|---|---|---|
| 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) |
| Private Relay de Apple | 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) |

Los intervalos no exageran nada por accidente de la simetría de los
modelos. El modelo de ruido de ±1 es deliberadamente simple y no es una
distribución empírica de errores; su lección es que la confianza en el rango
exige incertidumbre en las puntuaciones asignadas, no solo un segundo
conjunto de pesos.

## ¿Reprodujimos al revisor?

Sí, sobre el campo de 21 productos que midió el revisor, antes de que se
puntuara Obscura. El revisor informó (semilla 20260807, 200,000 muestras, su
propio código): 55.64% por delante de Mullvad; puntuación X 6.341–7.359 y
puntuación Y 5.749–6.652 bajo ruido; intervalos de rango 2–5, 1–15, 2–3 y
4–11 con mediana de Y 7. Nuestras ejecuciones sobre ese campo devolvieron
intervalos de rango y medianas idénticos, cotas de puntuación dentro de
0.005, y un 55.55% en el cruce; el registro de 21 productos se conserva en
`review/index/SCORES.md`, y las estadísticas por pares de Mullvad se
trasladan a la reejecución de 22 productos sin cambios dentro del error de
muestreo. El cruce tenía una respuesta exacta contra la que comprobar a
todos: con las puntuaciones que midió el revisor, la diferencia de
subpuntuaciones URnetwork−Mullvad era (+4, +3, −3, −3), y la fracción del
símplex de pesos del lado de URnetwork sale 109/196 ≈ 55.61%. Toda
estimación Monte Carlo, la nuestra y la del revisor, cae dentro del error de
muestreo (±0.22 puntos a 2σ para este tamaño de muestra) del valor exacto.
Un resultado externo que no pudiéramos reproducir habría sido un hallazgo;
uno que sí podemos reproducir también lo es. Las cifras del revisor son el
registro previo a la repuntuación: la repuntuación por activado por defecto
de URnetwork, más tarde ese mismo día, cambió su fila X, y el valor exacto
de cruce actual es 71/96, derivado y muestreado en las tablas de arriba. La
comprobación de 109/196 sigue reproduciéndose con las puntuaciones previas a
la repuntuación, que es lo que midió el revisor.

## Una afirmación retirada, registrada

Hasta el 2026-08-07 la página de metodología afirmaba que la colocación en Y
de URnetwork era robusta ante los pesos: que ninguna reponderación de las
partes de calidad podía poner su Y por debajo de 5.00. El veredicto del
revisor, **"matemáticamente cierto pero vacío como evidencia"**, se acepta
por completo. A cada subpuntuación Y de URnetwork se le asignó ≥ 5, así que
toda media ponderada convexa de ellas es ≥ 5 **por construcción**:
publicamos una propiedad de nuestras propias entradas como si fuera
evidencia de estabilidad, y la afirmación habría seguido siendo "cierta" por
muy equivocadas que estuvieran esas entradas. La misma objeción retira la
afirmación equivalente del eje X antes de que nadie la haga. Las
subpuntuaciones X de URnetwork son también todas ≥ 5, así que "ninguna
reponderación lleva la X por debajo de 5.00" está igual de garantizado e
igual de vacío. Ninguna de las dos afirmaciones vuelve a aparecer en este
corpus como evidencia. Lo que sí puede decirse desde el registro está en las
tablas de arriba: bajo reponderación la salida informativa es el rango, no
el cruce de líneas, y la prueba informativa de las líneas es la
incertidumbre de las puntuaciones, bajo la cual la Y de URnetwork en el
percentil 5.º es 5.747, una afirmación sobre un modelo de ruido simple, no
sobre el mundo. Lo que de verdad podría mover la Y por debajo de la línea es
evidencia: una medición externa que puntúe la latencia o el rendimiento por
debajo del punto medio de la escala. No existe ninguna en ninguna dirección,
y la regla del corpus de no inventar números es la razón de que las dos
partes de rendimiento se sitúen en el ancla de clase estructural de 5. La
afirmación se retira en lugar de borrarse porque el valor de este registro
es que conserva sus propios errores.

## Las colocaciones dentro del ruido

La sensibilidad de un solo punto no es Monte Carlo, pero responde a la misma
pregunta en las fronteras, así que se registra aquí con el resto.

- **Private Relay de Apple** se sitúa dentro del ruido de ambas líneas a la
  vez, +0.10 en X y −0.05 en Y, sola en el cruce de los ejes, así que
  debería leerse como sobre las líneas, no como residente de ningún
  cuadrante, incluida la celda Académica / de investigación en la que
  aterrizan sus signos estrictos. Sus juicios cortan en ambos sentidos:
  puntuar E o S un punto más bajo pone la X en 4.85 o 4.75, de vuelta a la
  izquierda de la línea, y puntuar S con 8 la pone claramente a la derecha,
  en 5.45. Esa sensibilidad es la razón de que el mapa la anote en lugar de
  reclamarla.
- **Obscura** se sitúa dentro del ruido de la línea Y desde arriba (+0.10).
  Su pertenencia a la derecha de la línea X es confiada, con +1.60, y
  robusta ante el ruido: 0 de 200,000 muestras de ruido de ±1 la ponen a la
  izquierda de la línea, con una X en el percentil 5.º de 6.12. Es sensible
  a los pesos, eso sí: el 32.6% de las ponderaciones muestreadas
  uniformemente la ponen a la izquierda, empujada por PQ 0 y V 6 cuando los
  pesos de verificación y poscuántico salen altos. El lado Y es un cara o
  cruz genuino y no se reclama para nada: la Y cae por debajo de 5.00 en el
  36% de las muestras de ruido de ±1 y en el 50.0% de las ponderaciones
  muestreadas, y la única parte T etiquetada como juicio la mueve a un lado
  u otro (T 5 da 4.85, T 7 da 5.35).
- **Mysterium** (−0.10), **Hola** (−0.15) y **Sentinel** (−0.20) se sitúan
  dentro del ruido por debajo de la línea Y, y su pertenencia por encima o
  por debajo no es una decisión confiada; sus déficits en X son la señal.
- Desde arriba, **TunnelBear**, **IPVanish** y **Hotspot Shield** superan la
  línea Y por solo 0.45–0.50 con puntuaciones L y T de clase centro de
  datos; un lector que puntuara la consistencia de sus flotas un punto más
  bajo los pondría sobre ella o por debajo.
- La línea X es agua despejada para todos los demás. Nada aparte de Apple se
  le acerca a menos de 0.40 (Windscribe desde la izquierda; IVPN a 0.70 y
  Obscura a 1.60 desde la derecha), así que ninguna repuntuación de un solo
  punto mueve a ningún otro producto al otro lado de la división
  verificable/basada-en-la-confianza. Apple es el único producto cuyo lado
  de esa línea depende legítimamente de un solo juicio.

## Reponderaciones que cambian el orden

Los pesos fijos ponen la estructura (S + E = 0.60) por encima de la
verificación (V = 0.30). Reponderar hacia la verificación con suficiente
fuerza, por ejemplo E 0.15 / S 0.25 / V 0.50 / PQ 0.10, y **Mullvad (7.00)
adelanta a URnetwork (6.90)** en el eje; empuja el peso de la verificación
todavía más arriba e IVPN acaba adelantándola también. Antes de la
repuntuación por activado por defecto bastaba con un ejemplo más suave (V en
0.45), y el número de todo el espacio era un cara o cruz: a lo largo de las
ponderaciones muestreadas uniformemente URnetwork iba entonces por delante
de Mullvad solo el 55.55% de las veces. Tras la repuntuación va por delante
el 74.0%, y el ejemplo de V 0.45 ya no invierte el orden (URnetwork 7.00,
Mullvad 6.70). El orden en la parte alta del eje descansa ahora en las
subpuntuaciones de estructura además de en los pesos, pero un lector que
pondere las auditorías y la evidencia judicial en torno a la mitad del eje
sigue obteniendo a Mullvad por delante y no se equivoca. Las páginas de cada
producto ya le dicen a ese lector que elija Mullvad o IVPN hasta que
URnetwork cierre su brecha de auditoría del protocolo.

La misma palanca tirada en el otro sentido es el ejemplo desarrollado de
IVPN: una ponderación dominada por la estructura, E 0.35 / S 0.45 / V 0.10 /
PQ 0.10, deja caer a IVPN a 4.90, justo a la izquierda de la línea, con
Mullvad en exactamente 5.00 sobre ella. La pertenencia de IVPN al cuadrante
superior derecho depende de que el peso de la verificación siga siendo
sustancial. Los pesos congelados lo despejan, y la dependencia se registra
aquí de todos modos.

En el eje de calidad, Eg en 0.20 es lo que eleva el margen de URnetwork y
mantiene abajo a WARP, Private Relay y Tor. Un lector que trate la identidad
de salida como algo de nicho comprime la dispersión en Y, y el rango Y de
URnetwork va del 1.º al 15.º a lo largo del espacio de pesos muestreado. Una
versión anterior de la página de metodología cerraba este análisis afirmando
que ninguna reponderación podía llevar la Y de URnetwork por debajo de 5.00;
esa afirmación está retirada, y la sección de arriba registra por qué.

## Qué movería este registro

Tres cosas moverían los resultados de esta página en lugar de discutir con
ellos. Que Obscura incorporase lo poscuántico cerraría el único par
degenerado (las comparaciones directas de arriba). Una medición externa de
la latencia o del rendimiento sustituiría las anclas estructurales que la
sección de la afirmación retirada nombra como el terreno blando del eje de
calidad. Y cualquier repuntuación bajo la regla permanente de la
[página de metodología](/docs/comparison-methodology) vuelve a ejecutar
ambas pruebas sobre todo el campo, como hicieron la adición de Obscura y la
repuntuación por activado por defecto.

## Script de reproducción

La implementación de comprobación cruzada, sobre el campo de 22 productos
con la fila de URnetwork posterior a la repuntuación. Regenera su fila de la
tabla de ejecuciones en `review/index/SCORES.md`, las estadísticas de rango
de URnetwork y la tabla por producto de arriba (Python 3, numpy ≥ 2).
Comprueba mediante aserción que cada total X e Y publicado se recomputa
exactamente a partir de las matrices de subpuntuaciones antes de muestrear,
y el orden de las extracciones importa para la reproducción exacta.

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