La equivalencia: cada familia puede emular a la otra
Existe una construcción que convierte cualquier tipo basado en estado en uno basado en operaciones y viceversa, y entender por qué esa equivalencia no autoriza a tratar la elección como indiferente.
Uno de los resultados fundacionales de esta área establece que las dos familias tienen el mismo poder expresivo: cualquier tipo replicado convergente construido con mezcla de estados admite una formulación equivalente con operaciones, y al revés. La demostración es constructiva y sorprendentemente breve en ambas direcciones, hasta el punto de que puede escribirse como dos adaptadores de treinta líneas. Es un resultado importante porque cierra la pregunta teórica: no hay estructuras que solo se puedan expresar de una manera. Y es un resultado que se cita mal con frecuencia, porque de la equivalencia expresiva se concluye a veces que la elección da igual en la práctica, y esa conclusión no se sigue ni de lejos. Esta lección demuestra las dos traducciones y después examina con cuidado qué es exactamente lo que la equivalencia no dice.
- Construir la emulación de un tipo basado en operaciones a partir de uno basado en estado, y verificar qué supuestos necesita.
- Construir la dirección contraria y reconocer el papel que juega el registro de operaciones como estado.
- Distinguir con precisión entre equivalencia expresiva y equivalencia de coste, de latencia o de complejidad operativa.
- Reconocer los invariantes que ninguna de las dos traducciones conserva y que deciden la elección real.
De estado a operaciones: la operación es un estado pequeño
La primera dirección es la más instructiva porque anticipa la lección siguiente. Dado un tipo basado en estado, se emula uno basado en operaciones así: cuando una réplica aplica una actualización local, en vez de enviar su estado completo, envía el estado mínimo que representa esa actualización aislada, es decir, el resultado de aplicar la actualización al elemento neutro del retículo. El receptor no aplica ninguna operación: mezcla ese estado pequeño con el suyo, exactamente igual que mezclaría un estado completo.
// Un incremento se convierte en un estado que solo contiene ese incremento
function comoOperacion(replica, n) {
return { [replica]: n }; // estado valido, mezclable, diminuto
}
// El receptor no distingue esto de un estado completo
const nuevo = mezclar(estadoLocal, comoOperacion("A", 1));
Lo que hace correcta a esta traducción es que el mensaje sigue siendo un elemento del retículo, de modo que hereda las tres leyes intactas: mezclarlo dos veces no daña, mezclarlo desordenado tampoco y perderlo solo retrasa. Pero fíjate en la condición que la hace funcionar en el caso del contador y que no se cumple en general: el mensaje pequeño solo es suficiente si la mezcla puede recomponer el estado a partir de fragmentos independientes. Cuando la actualización depende del estado previo de una manera que el fragmento no captura —una inserción cuya posición se define respecto a lo ya existente, un borrado que debe dominar causalmente a lo que borra— el fragmento aislado ya no basta, y hay que incluir el contexto causal necesario para que la mezcla lo coloque bien.
Esa es precisamente la frontera que separa la traducción ingenua de la construcción seria, y tiene nombre: el fragmento debe formar un intervalo causalmente consistente con lo que el receptor ya tiene. Cuando no se puede garantizar, se reenvía el estado completo, que siempre es un intervalo válido por vacuidad. La lección siguiente convierte esta observación en una técnica de primera clase.
Un ejemplo hace visible la diferencia entre los dos casos. En un contador, el fragmento de una réplica es autosuficiente porque la mezcla por máximo lo coloca correctamente venga cuando venga. En un conjunto con altas y bajas, el fragmento de una baja consiste en las etiquetas retiradas, y esas etiquetas solo tienen sentido para quien conoce las altas correspondientes: si la baja llega antes que el alta que anula, el receptor mezcla una retirada de algo que no tiene y, cuando el alta llegue después, el elemento reaparecerá indebidamente. No es que la traducción falle, es que el fragmento no llevaba consigo el contexto que lo hacía interpretable.
// El fragmento autosuficiente frente al que necesita contexto
const fragmentoContador = { A: 7 }; // se coloca solo, venga cuando venga
const fragmentoBaja = { retiradas: ["A:3f2"] }; // sin conocer el alta A:3f2 no significa nada
De operaciones a estado: el registro como estado
La dirección contraria es aún más directa y por eso resulta menos interesante de lo que parece. Dado un tipo basado en operaciones, se emula uno basado en estado tomando como estado el conjunto de operaciones observadas, cada una con su identificador único. La mezcla es la unión de conjuntos, que es idempotente, conmutativa y asociativa sin necesidad de demostrar nada. El valor observable se obtiene aplicando todas las operaciones del conjunto en un orden compatible con la causalidad, y la conmutatividad exigida al tipo original garantiza que cualquiera de esos órdenes da lo mismo.
// El estado es el conjunto de operaciones vistas; la mezcla es la union
const mezclarRegistro = (a, b) => new Map([...a, ...b]); // clave: id de operacion
function proyectar(registro, inicial) {
return [...registro.values()]
.sort(porOrdenCausalYDesempate)
.reduce(efectuar, inicial);
}
Hay un detalle en la función de proyección que conviene no pasar por alto, porque es donde se esconde la condición del teorema. El criterio de ordenación debe respetar la causalidad y desempatar de forma determinista los pares concurrentes, y ese desempate solo es inocuo si las operaciones concurrentes conmutan de verdad. Si no conmutan, la traducción no falla ruidosamente: produce un tipo que converge —porque todas las réplicas ordenan igual— pero cuyo resultado puede no ser el que el tipo original habría dado, y detectar esa discrepancia exige compararlos caso por caso. La equivalencia es un teorema sobre tipos que ya cumplen sus hipótesis, no un procedimiento que arregla tipos mal diseñados.
La construcción es válida y demuestra el teorema, y a la vez es la ilustración más clara de por qué la equivalencia no resuelve nada por sí sola. El estado que acabamos de fabricar es el registro completo de todo lo que ha ocurrido desde el principio de los tiempos, y crece de forma monótona con la actividad y no con el tamaño del documento. Un contador con un millón de incrementos tiene un estado de un millón de entradas, cuando el tipo original cabía en un entero por réplica. La traducción es correcta y es inutilizable, y esas dos cosas no son incompatibles.
Puestas una al lado de la otra, las dos direcciones dibujan una simetría instructiva. La primera reduce el tamaño del mensaje y aumenta lo que el canal tiene que garantizar; la segunda relaja lo que el canal tiene que garantizar y aumenta el tamaño del estado. En ambos casos hay una magnitud que se contrae y otra que se dilata, y la suma de las dos permanece aproximadamente constante. Esa observación no es un teorema, pero es una heurística fiable para detectar propuestas demasiado buenas: si alguien afirma haber conseguido las dos contracciones a la vez sin cambiar nada más, conviene preguntar dónde ha ido a parar la información.
flowchart LR E[tipo basado en estado] -->|enviar el fragmento minimo| O[tipo basado en operaciones] O -->|el estado es el registro de operaciones| E O --> C1[coste en memoria del registro] E --> C2[coste en ancho de banda del envio] style E fill:#89b4fa,color:#11111b style O fill:#cba6f7,color:#11111b style C1 fill:#f38ba8,color:#11111b style C2 fill:#f38ba8,color:#11111b
Si el registro completo fuese aceptable, todos los sistemas serían de operaciones y el asunto estaría cerrado. Lo que hace practicable a un tipo basado en operaciones es la capacidad de descartar operaciones que ya no hacen falta, y esa capacidad no se deduce del teorema de equivalencia: hay que demostrarla para cada tipo por separado, y a veces no existe. Un conjunto con altas y bajas puede colapsar pares en una lápida y después podar la lápida cuando todos la han visto. Una secuencia de texto no puede olvidar los identificadores de los caracteres borrados sin arriesgarse a que una inserción tardía se coloque mal. La diferencia entre un tipo que compacta bien y uno que no es la que decide si el sistema es viable a los tres años, y la equivalencia no dice absolutamente nada sobre ella.
Lo que la equivalencia dice y lo que no dice
Un teorema de equivalencia expresiva afirma que para todo tipo de una familia existe otro de la otra que produce las mismas observaciones. No afirma nada sobre el tamaño de los mensajes, ni sobre el tamaño del estado, ni sobre el tiempo de aplicar, ni sobre la latencia hasta que un cambio remoto es visible, ni sobre cuánta infraestructura hay que operar, ni sobre lo difícil que resulta depurar el resultado un martes por la noche. Todas esas magnitudes pueden cambiar en órdenes de magnitud bajo la traducción, y de hecho cambian.
Conserva las observaciones
Lo que un lector puede ver del dato es idéntico en ambas formulaciones. Ese es el contenido completo del teorema.
No conserva el tamaño
El registro de operaciones como estado crece con la actividad histórica; el estado original crecía con el contenido actual.
No conserva el contrato de red
Una traducción puede convertir un sistema que tolera cualquier canal en otro que exige entrega causal fiable, o al revés.
No conserva la operabilidad
Copias de seguridad, migraciones, poda, reparación tras corrupción y depuración se comportan de forma distinta en cada familia.
Hay una forma rápida de recordar el alcance del teorema y sirve de filtro contra los abusos habituales. La equivalencia es una afirmación sobre lo que un observador externo puede distinguir mirando únicamente las respuestas a las consultas, y ese observador es deliberadamente ciego a todo lo demás: no ve cuánto ocupa el estado, no ve cuánto tarda una operación en propagarse, no ve si el sistema sobrevive a un cliente que reaparece tras un año, no ve la factura de tráfico. Cuando alguien invoca el teorema para zanjar una discusión de diseño, la pregunta útil es siempre la misma: la magnitud que estamos discutiendo, ¿la ve ese observador? Si la respuesta es no, el teorema no dice nada sobre ella.
La discusión entre estructuras convergentes y transformación operacional arrastra durante años el mismo malentendido, y ahora tienes la herramienta para desmontarlo. Se puede demostrar que ambos enfoques alcanzan los mismos comportamientos observables para los tipos que interesan, y de ahí se salta con frecuencia a que la elección es cuestión de gusto. No lo es: la transformación operacional necesita típicamente un servidor que ordene, escala peor con el número de pares que editan a la vez y tiene un historial de funciones de transformación con errores sutiles que tardaron años en detectarse. Los CRDT pagan en metadatos y en complejidad de compactación. Son perfiles de coste muy distintos que producen productos muy distintos, y la equivalencia de comportamiento observable no toca ninguna de esas diferencias.
Conviene además señalar una asimetría de las dos traducciones que rara vez se menciona y que tiene consecuencias. La dirección de estado a operaciones degrada el contrato de red: el tipo original toleraba cualquier canal y el emulado necesita, en general, que los fragmentos lleguen sin huecos por emisor. La dirección de operaciones a estado degrada el consumo de memoria, y lo hace de forma no acotada. Ninguna de las dos es neutra, y por tanto el teorema no describe una simetría perfecta entre dos representaciones intercambiables: describe dos caminos, cada uno con su peaje, entre dos regiones distintas del espacio de diseño.
Usar la equivalencia sin abusar de ella
Hay un modo productivo de usar el resultado, y no consiste en concluir que la elección es libre. Consiste en tratarlo como una licencia para razonar en la formulación que resulte más cómoda para cada tarea concreta, sabiendo que las conclusiones se transfieren. Demostrar que un tipo converge suele ser mucho más fácil en la formulación de estado, porque se reduce a comprobar tres leyes y la monotonía de las actualizaciones. Diseñar la interfaz de usuario y razonar sobre la intención suele ser más fácil en la de operaciones, porque las operaciones son lo que el usuario hizo. La equivalencia permite hacer cada cosa donde es fácil sin cambiar de sistema.
En la práctica esto se traduce en tres usos concretos que rinden de inmediato. El primero es probar la corrección donde es barato: escribir la formulación de estado de un tipo aunque el sistema real sea de operaciones, y usarla como oráculo en las pruebas basadas en propiedades, comprobando que aplicar cualquier permutación de operaciones coincide con mezclar los estados equivalentes.
// La formulacion de estado como oraculo de la de operaciones
function pruebaDeEquivalencia(operaciones) {
const porOperaciones = permutar(operaciones).map(
(orden) => orden.reduce(efectuar, inicial)
);
const porEstado = operaciones
.map(comoFragmentoDeEstado)
.reduce(mezclar, neutro);
return porOperaciones.every((r) => observar(r) === observar(porEstado));
}
El segundo uso es elegir el formato de intercambio con independencia del motor interno: nada obliga a que el objeto que viaja por el cable tenga la misma forma que el que vive en memoria, y la traducción autoriza a mantener un registro de operaciones por dentro y publicar fragmentos mezclables por fuera. La lección siguiente muestra que eso es exactamente lo que hacen las implementaciones más usadas, y ahora ya sabemos por qué les está permitido.
El tercero es migrar sin reescribir el modelo. Si un sistema de operaciones se ahoga por el coste de la difusión causal, la traducción indica un camino incremental: encapsular el estado observable como elemento de un retículo, publicarlo por anti-entropía para los pares problemáticos y conservar el registro de operaciones para el resto. No es una reescritura, es un segundo canal, y el teorema garantiza que los dos convergen al mismo sitio.
Merece la pena situar este teorema en el lugar exacto que le corresponde, porque es un caso especialmente limpio de una confusión que atraviesa toda la ingeniería de software. Un resultado de equivalencia expresiva es una afirmación sobre funciones: dice que el conjunto de comportamientos observables alcanzable con una familia coincide con el alcanzable con la otra. Un sistema en producción no es una función. Es una función más un perfil de consumo de recursos, más un contrato con la red, más un modo de fallar cuando el contrato se incumple, más un procedimiento de recuperación, más un conjunto de personas que tendrán que entender por qué un valor es el que es a las tres de la madrugada. La equivalencia habla del primer término de esa lista y guarda un silencio absoluto sobre los cinco restantes, y son los cinco restantes los que deciden si un producto se sostiene. Fíjate en que este mismo patrón te resulta familiar de otros sitios y siempre con la misma moraleja. Todo lenguaje de programación universal computa lo mismo que cualquier otro, y nadie deduce de ahí que elegir el lenguaje sea indiferente. Toda base de datos relacional puede simularse con ficheros y código, y sin embargo la elección importa muchísimo. El teorema fija el límite exterior de lo posible, y dentro de ese límite queda intacto el problema de ingeniería completo, que consiste en elegir la representación cuyo perfil de coste encaja con la forma de tu carga real. Y hay un último matiz que este nivel deja preparado y que conviene enunciar antes de pasar a la lección siguiente: la existencia misma de las dos traducciones sugiere que las familias no son dos puntos separados sino los extremos de un continuo, porque la primera traducción funcionaba enviando fragmentos de estado y solo fallaba cuando el fragmento perdía su contexto causal. Si esa es la única dificultad, entonces cabe un diseño intermedio que envíe fragmentos con el contexto justo, obteniendo mensajes del tamaño de la operación con las garantías algebraicas del estado. Ese diseño existe, tiene veinte años menos que el teorema y es lo que hizo que la familia basada en estado dejara de ser una curiosidad elegante para convertirse en la base de sistemas reales.
- Toma tu contador basado en estado y escribe el adaptador que emite fragmentos mínimos; comprueba que el receptor no necesita saber que son fragmentos.
- Toma un tipo basado en operaciones sencillo y constrúyelo como conjunto de operaciones con unión; verifica que la proyección es estable ante cualquier orden compatible.
- Mide el tamaño del estado en las dos formulaciones después de simular cien mil actualizaciones y anota el cociente.
- Busca en tu modelo una operación cuyo fragmento aislado no baste para el receptor y escribe qué contexto causal habría que adjuntarle.
- Para cada tipo de tu aplicación, decide en qué formulación vas a razonar la corrección y en cuál vas a razonar la interfaz, y anota por qué.