Comparison map

Sensibilidad de la comparativa

13 min de lecturaView as markdown ↗

Esta página registra las dos pruebas de estabilidad Monte Carlo que hay detrás del mapa de comparativas: 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 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.

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ísticaPrueba 1, pesos muestreadosPrueba 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 X74.0% (exactamente 71/96)99.9%
Por delante de Obscura en X100% (degenerado; ver más abajo)93.9%
Por delante de NymVPN en X67.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.00nunca, 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.

ProductoRango X, pesosRango X, ruido de puntuaciónRango Y, pesosRango Y, ruido de puntuación
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)
Private Relay de Apple5–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)

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 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.

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