Comprobar las tres propiedades con casos concretos
Conmutatividad, asociatividad e idempotencia dejan de ser álgebra cuando se escriben como cinco comprobaciones ejecutables sobre tres réplicas, y la salida enseña además que una prueba solo vale lo que vale su escenario.
Las tres propiedades que hacen convergente a esta estructura suelen presentarse como una demostración de cuatro líneas que casi nadie lee y menos gente aún traduce a código. Aquí haremos lo contrario: escribirlas como pruebas concretas sobre estados concretos, ejecutarlas y mirar la salida, porque una propiedad comprobada empíricamente sobre el escenario adecuado protege más que una demostración correcta sobre una implementación que no es la que se ejecuta. El experimento tiene además un segundo resultado, menos esperado y bastante más útil que el primero: veremos que la mezcla defectuosa de la lección anterior pasa las tres propiedades con el escenario obvio y solo falla cuando el escenario incluye el caso que la rompe. Esa es la lección de método que conviene llevarse por delante de cualquier suite de pruebas de convergencia: la propiedad no se comprueba en el vacío, se comprueba sobre una historia, y una historia que no ejerce el caso difícil declara correcta a una implementación que pierde datos.
- Traducir conmutatividad, asociatividad e idempotencia a comprobaciones ejecutables sobre estados reales.
- Construir un escenario de tres réplicas con historias solapadas y una comprobación de todos los órdenes de entrega.
- Interpretar la salida y saber qué protege cada propiedad frente a qué fallo de red.
- Comprobar empíricamente que un escenario insuficiente declara correcta a una implementación defectuosa.
Qué protege cada propiedad
Antes de escribir las pruebas conviene saber contra qué fallo real defiende cada una, porque las tres se enuncian igual de rápido y protegen contra cosas muy distintas. La conmutatividad dice que mezclar en un orden o en el otro da lo mismo, y protege contra el hecho más básico de una red sin coordinación: dos réplicas se sincronizan a la vez y cada una recibe primero lo que la otra recibe después. La asociatividad dice que agrupar las mezclas de una manera o de otra da lo mismo, y protege contra las topologías: que un cambio llegue directo o pasando por un intermediario que ya lo había combinado con otros no puede cambiar el resultado. La idempotencia dice que volver a mezclar algo ya conocido no altera nada, y protege contra el reenvío, que en cualquier transporte real ocurre constantemente.
Conmutativa
Defiende del orden de llegada. Sin ella, dos réplicas que reciben los mismos mensajes en orden distinto acaban en estados distintos y ya no se recuperan.
Asociativa
Defiende de la topología. Sin ella, un mensaje reenviado por un intermediario que lo combinó con otros produce un resultado que el envío directo no produce.
Idempotente
Defiende del reintento. Sin ella, la entrega al menos una vez que ofrecen los transportes reales deja de ser suficiente y hace falta deduplicar.
Las tres juntas
Convierten la mezcla en una operación de retículo, y esa es exactamente la condición que hace que el estado converja sin coordinación de ningún tipo.
Hay una cuarta condición que no es una propiedad de la mezcla sino de las operaciones y que conviene nombrar porque se olvida: cada operación local tiene que ser monótona, es decir, no puede quitar información del estado. El alta añade una etiqueta y la baja añade una marca; ninguna de las dos borra nada. Si en algún momento se optimiza la clase eliminando etiquetas muertas del mapa de altas, esa monotonía se rompe y las tres propiedades dejan de bastar, porque la información destruida puede volver desde otra réplica y producir una resurrección.
El escenario y el comprobador
El escenario necesita tres réplicas con historias que se solapen de verdad. Una que añade dos cosas, otra que parte de lo que vio de la primera y luego añade y retira, y una tercera completamente independiente que añade uno de los mismos valores. Ese tercer detalle es el que hace útil al escenario y se justificará solo en la última sección.
const { ORSet, serializar } = require("./orset.js");
function escenario() {
const A = new ORSet("A");
A.add("pan"); A.add("cafe");
const B = ORSet.merge(new ORSet("B"), A); // B parte de lo que vio de A
B.add("te"); B.remove("pan");
const C = new ORSet("C");
C.add("cafe"); C.add("sal"); // C anade cafe sin saber de A
return { A, B, C };
}
let fallos = 0;
function comprobar(nombre, izq, der) {
const ok = serializar(izq) === serializar(der);
if (!ok) fallos += 1;
console.log((ok ? "OK " : "FALLA ") + nombre);
}
El comprobador compara la serialización estable de la lección anterior en lugar de los objetos, y esa decisión es la que hace que las pruebas signifiquen algo. Comparar por identidad daría siempre falso porque merge construye objetos nuevos; comparar recursivamente los campos incluiría replica y reloj, que son contabilidad local y difieren legítimamente entre réplicas convergentes. La serialización ordenada y sin campos locales es la definición operativa de estos dos estados son el mismo, y sin ella no hay prueba de convergencia posible.
const { A, B, C } = escenario();
comprobar("conmutativa merge A B == merge B A", ORSet.merge(A, B), ORSet.merge(B, A));
comprobar("conmutativa merge A C == merge C A", ORSet.merge(A, C), ORSet.merge(C, A));
comprobar("asociativa A con BC == AB con C ",
ORSet.merge(A, ORSet.merge(B, C)), ORSet.merge(ORSet.merge(A, B), C));
comprobar("idempotente merge A A == A ", ORSet.merge(A, A), A);
comprobar("idempotente merge AB B == merge A B",
ORSet.merge(ORSet.merge(A, B), B), ORSet.merge(A, B));
La salida y los seis órdenes de entrega
Las cinco comprobaciones anteriores son puntuales. La prueba que de verdad convence es la que enumera todos los órdenes de entrega posibles y verifica que producen un único estado, porque es la traducción literal de lo que promete la convergencia: da igual cómo se propague la información mientras se propague entera.
const ordenes = [[A,B,C],[A,C,B],[B,A,C],[B,C,A],[C,A,B],[C,B,A]];
const huellas = new Set(ordenes.map(
([x, y, z]) => serializar(ORSet.merge(ORSet.merge(x, y), z))
));
console.log("ordenes de entrega probados:", ordenes.length, "estados distintos:", huellas.size);
console.log("estado comun:", [...huellas][0]);
console.log(fallos === 0 ? "TODO CORRECTO" : fallos + " FALLOS");
OK conmutativa merge A B == merge B A
OK conmutativa merge A C == merge C A
OK asociativa A con BC == AB con C
OK idempotente merge A A == A
OK idempotente merge AB B == merge A B
ordenes de entrega probados: 6 estados distintos: 1
estado comun: {"altas":{"cafe":["A:2","C:1"],"pan":["A:1"],"sal":["C:2"],"te":["B:1"]},"bajas":["A:1"]}
TODO CORRECTO
Seis órdenes, un solo estado. Y el estado común dice bastante más que la palabra correcto. El elemento cafe conserva las dos etiquetas, la de A y la de C, porque fueron dos altas independientes del mismo valor y ninguna es la otra; el conjunto presenta un solo cafe al usuario pero recuerda que hubo dos aportaciones, y eso es exactamente lo que hará falta para que una retirada que solo vio una de ellas no elimine la otra. El elemento pan sigue en el mapa con su etiqueta A:1, que figura también entre las bajas: está muerto y su lápida permanece. Los valores presentes son cafe, sal y te.
Enumerar los órdenes completos demuestra convergencia pero oculta un fallo frecuente: una implementación puede llegar al mismo sitio pasando por estados intermedios en los que el usuario ve cosas imposibles, como un elemento que parpadea o una retirada que se aplica antes de que exista lo retirado. Añade a la prueba una comprobación de que en ningún punto intermedio hay una etiqueta en el conjunto de bajas que no aparezca en ninguna alta conocida. No es un requisito de convergencia, pero sí de que la interfaz no muestre estados que no ocurrieron.
flowchart TD A[estado A] --> M1[mezcla en cualquier orden] B[estado B] --> M1 C[estado C] --> M1 M1 --> U[un unico estado final] U --> P1[conmutativa: el orden no importa] U --> P2[asociativa: la agrupacion no importa] U --> P3[idempotente: repetir no importa] style U fill:#a6e3a1,color:#11111b
Una prueba solo vale lo que vale su escenario
Aquí llega el resultado que justifica la lección entera. Repitamos exactamente las mismas tres comprobaciones sobre la mezcla defectuosa de la lección anterior, la que reemplaza los conjuntos internos en lugar de unirlos, usando únicamente las réplicas A y B.
function mergeMalo(a, b) {
const s = new ORSet(a.replica);
s.altas = new Map([...a.altas, ...b.altas]); // reemplaza en vez de unir
s.bajas = new Set([...a.bajas, ...b.bajas]);
return s;
}
const chk = (n, x, y) => console.log((serializar(x) === serializar(y) ? "OK " : "FALLA ") + n);
chk("conmutativa", mergeMalo(A, B), mergeMalo(B, A));
chk("asociativa ", mergeMalo(A, mergeMalo(B, C)), mergeMalo(mergeMalo(A, B), C));
chk("idempotente", mergeMalo(A, A), A);
console.log("--- ahora con A y C, que anaden cafe de forma independiente ---");
chk("conmutativa", mergeMalo(A, C), mergeMalo(C, A));
console.log("A con C:", serializar(mergeMalo(A, C)));
console.log("C con A:", serializar(mergeMalo(C, A)));
OK conmutativa
OK asociativa
OK idempotente
--- ahora con A y C, que anaden cafe de forma independiente ---
FALLA conmutativa
A con C: {"altas":{"cafe":["C:1"],"pan":["A:1"],"sal":["C:2"]},"bajas":[]}
C con A: {"altas":{"cafe":["A:2"],"pan":["A:1"],"sal":["C:2"]},"bajas":[]}
Las tres propiedades pasaron con A y B, y fallaron en cuanto entró C. La razón es precisa: B heredó de A la etiqueta A:2 para cafe, de modo que reemplazar el conjunto o unirlo daba el mismo resultado porque el conjunto era idéntico. Solo cuando dos réplicas etiquetan el mismo valor de forma independiente hay dos conjuntos distintos bajo la misma clave, que es la única situación en la que reemplazar difiere de unir. El escenario obvio no la contenía y por eso certificó una implementación que destruye etiquetas.
El hallazgo de esta última sección parece un accidente del ejemplo y es en realidad la ley que gobierna todas las pruebas de sistemas convergentes, así que conviene enunciarla en su forma general. Las tres propiedades son cuantificadores universales: para todo par de estados, para toda terna, para todo estado. Una prueba, en cambio, es una instancia: para estos estados. La distancia entre lo que la propiedad afirma y lo que la prueba comprueba es infinita en el sentido literal, y esa distancia no se cierra añadiendo más comprobaciones sino eligiendo mejor los estados, porque el espacio de historias posibles no se muestrea uniformemente: casi todas las historias son fáciles y la corrección se juega en un subconjunto minúsculo. En esta estructura el subconjunto difícil está caracterizado con una precisión inusual y se puede enumerar: dos réplicas que producen etiquetas distintas bajo la misma clave, una baja que precede causalmente a un alta del mismo valor, una baja concurrente con un alta del mismo valor, un mismo delta entregado dos veces, y una etiqueta que llega después de que su retirada ya haya sido observada. Cinco situaciones. Una suite que las contenga las cinco vale más que diez mil casos aleatorios, y una que no contenga ninguna vale exactamente cero por muchos casos que tenga, que es lo que acabamos de ver ocurrir. De ahí se sigue el método que conviene adoptar y que casi nadie adopta: escribir el catálogo de historias difíciles antes que las pruebas, derivarlo del diseño y no del código, y tratarlo como parte de la especificación. El catálogo se obtiene preguntando, para cada decisión de diseño tomada en la primera lección, qué historia la ejerce; la unión de etiquetas bajo la misma clave se ejerce con dos altas independientes del mismo valor, la eliminación observada se ejerce con una baja concurrente, la unicidad de la etiqueta se ejerce con un ciclo de alta, baja y alta. Cada decisión de diseño engendra su prueba, y una decisión sin prueba correspondiente es una decisión que nadie sabe si se implementó. Y hay un corolario que ahorra mucho tiempo cuando la estructura crece: la generación aleatoria de historias, que es la técnica que uno instintivamente busca aquí, solo empieza a ser útil después de que el catálogo esté cubierto, porque encuentra combinaciones inesperadas de casos difíciles pero es pésima encontrando el caso difícil por primera vez. Un generador que produzca altas y bajas de valores tomados de un alfabeto de cien elementos casi nunca generará dos altas independientes del mismo valor; reduce el alfabeto a tres y las generará constantemente. Esa reducción deliberada del espacio para concentrar la probabilidad en la zona interesante es el único ajuste que convierte una prueba aleatoria en algo que encuentra fallos, y no es un truco: es reconocer que la corrección de un sistema convergente vive entera en su región más improbable.
- Reproduce las cinco comprobaciones y la enumeración de órdenes, y verifica que obtienes un único estado con las etiquetas exactas de esta lección.
- Escribe el catálogo de las cinco historias difíciles como cinco pruebas con nombre propio, cada una documentando qué decisión de diseño ejerce.
- Sustituye la mezcla por la defectuosa y comprueba que ahora tu suite falla, midiendo cuántas de las cinco pruebas lo detectan.
- Amplía la enumeración de órdenes a cuatro réplicas y comprueba que las veinticuatro permutaciones siguen dando un único estado.
- Añade un generador aleatorio de historias con un alfabeto de tres valores y déjalo correr diez mil iteraciones comparando siempre contra una réplica de referencia.