¿Por qué Rust es ideal para pruebas A/B?
\nCuando hablamos de pruebas A/B, normalmente pensamos en Python, R o JavaScript. Pero Rust es un caballo oscuro que, en condiciones reales de combate, demuestra ser más rápido, más seguro y más barato. Si tu backend está escrito en Rust, puedes integrar experimentos directamente en el núcleo del sistema sin la sobrecarga de microservicios y Redis.
\nImagina: cambias el algoritmo de recomendaciones, el color del botón "Comprar" o la lógica de análisis. Un error en el código de una prueba A/B en Python puede tumbar el servidor. En Rust, simplemente no compilarás si te saltas una rama de condición. El compilador es tu mejor QA.
\nEn este artículo, analizaremos las pruebas A/B en Rust desde la arquitectura hasta la producción. Aprenderás cómo hacer pruebas divididas sin dolor, calcular estadísticas y no perder usuarios.
\n\nArquitectura de una prueba A/B en Rust: tres pilares
\nCualquier experimento en una aplicación Rust se sostiene sobre tres componentes:
\n- \n
- Aleatorizador — ¿quién va al grupo A y quién al B? \n
- Almacén de asignaciones — para que el usuario no salte entre grupos en cada solicitud. \n
- Registro y estadísticas — calculamos la conversión y el valor p. \n
Escribamos un módulo simple pero funcional. Sin frameworks, solo Rust y su biblioteca estándar.
\n\nPaso 1. Aleatorización basada en hash
\nLa aleatorización determinista es la clave de la estabilidad. Si usas rand::random(), con cada reinicio del servidor el usuario podría saltar a otro grupo. Usamos un hash ponderado del ID del usuario y el nombre del experimento.
use std::collections::hash_map::DefaultHasher;\nuse std::hash::{Hash, Hasher};\n\n#[derive(Debug, Clone, Copy, PartialEq)]\npub enum Group {\n Control,\n Treatment,\n}\n\npub fn assign_group(user_id: &str, experiment: &str, treatment_weight: f64) -> Group {\n let mut hasher = DefaultHasher::new();\n user_id.hash(&mut hasher);\n experiment.hash(&mut hasher);\n let hash = hasher.finish();\n\n let normalized = (hash as f64) / (u64::MAX as f64);\n if normalized < treatment_weight {\n Group::Treatment\n } else {\n Group::Control\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_deterministic() {\n let g1 = assign_group("user_42", "button_color_v2", 0.5);\n let g2 = assign_group("user_42", "button_color_v2", 0.5);\n assert_eq!(g1, g2);\n }\n}\n\n\nEste código garantiza que el mismo usuario siempre estará en el mismo grupo para un experimento específico. El peso treatment_weight es la proporción de tráfico que va al grupo de prueba (por ejemplo, 0.1 = 10%).
Paso 2. Almacén: ¿caché o base de datos?
\nPara proyectos de alta carga, almacenar las asignaciones en memoria es un riesgo. Si el servidor se cae, el usuario pierde el grupo. Es mejor usar Redis o SQLite. Pero para simplificar, mostraremos un caché en memoria con TTL.
\n\nuse std::collections::HashMap;\nuse std::time::{Duration, Instant};\n\nstruct AssignmentCache {\n store: HashMap<String, (Group, Instant)>,\n ttl: Duration,\n}\n\nimpl AssignmentCache {\n fn new(ttl_secs: u64) -> Self {\n Self {\n store: HashMap::new(),\n ttl: Duration::from_secs(ttl_secs),\n }\n }\n\n fn get(&mut self, key: &str) -> Option<Group> {\n if let Some((group, time)) = self.store.get(key) {\n if time.elapsed() < self.ttl {\n return Some(*group);\n }\n }\n None\n }\n\n fn set(&mut self, key: String, group: Group) {\n self.store.insert(key, (group, Instant::now()));\n }\n}\n\n\nEn un proyecto real, reemplace HashMap por dashmap o Redis; esto proporcionará seguridad en hilos y persistencia.