wandres.dev
GLITCHES Y CONSISTENCIA · El diamante y el estado imposible

El rombo: dos caminos hasta el mismo nodo

La topología que rompe las implementaciones ingenuas. Por qué aparece sin que nadie la busque, cómo se detecta en un grafo real y qué hace falta exactamente para resolverla.

⏱ 16 min

Un rombo es un nodo al que se llega desde otro por dos caminos de longitud distinta. Es la topología más inocente del mundo y la que hace fallar a cualquier motor que propague sin pensar en el orden. Lo peor es que no hay que buscarla: aparece sola en cuanto un valor se usa dos veces, y en una aplicación real hay cientos.

🎯 Al terminar esta lección sabrás
  • Reconocer un rombo y calcular las longitudes de sus caminos.
  • Ver por qué la topología aparece sin intención en código normal.
  • Enumerar las condiciones exactas que hacen que un rombo produzca un fallo.
  • Detectar rombos en un grafo real de forma automática.

La forma

Un rombo es esto: un nodo raíz, dos caminos que salen de él, y un nodo donde los dos caminos se reúnen.

const a = senal(1);
const b = memo(() => a() + 1);      // camino corto: a -> b
const c = memo(() => b() * 2);      // camino largo: a -> b -> c
const d = memo(() => a() + c());    // se reunen aqui

Desde a hasta d hay dos caminos: uno directo, de longitud uno, y otro pasando por b y c, de longitud tres. Cuando a cambia, d recibe la noticia por los dos caminos, y por el corto llega antes.

flowchart TB
A[a] --> B[b]
A --> D[d]
B --> C[c]
C --> D
style A fill:#89b4fa,color:#11111b
style B fill:#cba6f7,color:#11111b
style C fill:#cba6f7,color:#11111b
style D fill:#f9e2af,color:#11111b

La condición formal es precisa: existe un nodo con al menos dos caminos entrantes desde un mismo ancestro, de longitudes distintas. Si las longitudes fueran iguales, cualquier orden por niveles funcionaría; el problema lo crea la asimetría.

Aparece sin buscarla

El ejemplo de arriba parece artificial. En código real la topología surge sola, y de tres formas muy frecuentes.

Un valor usado en dos sitios. Si usuario se lee tanto para el nombre de la cabecera como para calcular los permisos, y algo depende de las dos cosas, ya hay un rombo.

const usuario = senal({ nombre: 'Ana', rol: 'editor' });
const nombre = memo(() => usuario().nombre);
const permisos = memo(() => calcularPermisos(usuario().rol));
const cabecera = memo(() => `${nombre()} ${permisos().puedeEditar ? '(editor)' : ''}`);

Una derivación que lee su propia entrada. Muy habitual: un total que necesita también la lista original para mostrar un desglose.

const total = memo(() => elementos().reduce((s, e) => s + e.precio, 0));
const resumen = memo(() => `${elementos().length} elementos, ${total()} euros`);

El estado compartido de una aplicación. Cualquier valor global —el tema, el idioma, el usuario, la ruta— está leído por decenas de derivaciones que a su vez se combinan. Los rombos se multiplican y se anidan.

📝
Un rombo no es un ciclo

Conviene no confundirlos. Un ciclo hace que el grafo deje de ser acíclico y produce recursión infinita. Un rombo mantiene el grafo perfectamente acíclico y es completamente válido: solo obliga al motor a respetar el orden topológico. Los rombos no hay que evitarlos ni son un mal diseño; son la forma natural de un grafo de dependencias.

Las condiciones del fallo

Un rombo no produce necesariamente un fallo. Hacen falta tres condiciones a la vez, y conviene tenerlas claras porque explican por qué a veces el bug aparece y a veces no.

Primera: propagación en orden de descubrimiento. Si el motor recorre a los observadores en el orden en que están en la lista y ejecuta al visitarlos, el camino corto llegará antes al nodo de reunión.

Segunda: evaluación durante la propagación. Si al llegar al nodo de reunión se recalcula inmediatamente, se calculará con la mitad de sus entradas nuevas y la otra mitad viejas.

Tercera: el resultado es observable. Si el nodo de reunión es un memo puro y nadie lo lee en ese instante intermedio, el valor incorrecto existe una fracción de milisegundo y se sobrescribe sin que nadie lo vea. El fallo se vuelve visible cuando el nodo de reunión es un efecto: escribe en el DOM, lanza una petición o registra un log, y ese valor imposible se hace parte del mundo.

De las tres, la que más se malinterpreta es la tercera. Un glitch en un memo puro es invisible; un glitch en un efecto es un bug de producción. Por eso los motores que se saltan la garantía tienden a fallar justo en el sitio donde más duele.

Detectarlos y resolverlos

Detectar rombos automáticamente

Como los rombos son puramente topológicos, se pueden encontrar recorriendo el grafo, sin ejecutar nada. Un nodo participa en un rombo si dos de sus fuentes comparten un ancestro.

function ancestros(nodo, acc = new Set()) {
  for (const f of nodo.fuentes ?? []) {
    if (acc.has(f)) continue;
    acc.add(f);
    ancestros(f, acc);
  }
  return acc;
}

function rombosEn(nodo) {
  const fuentes = [...(nodo.fuentes ?? [])];
  const encontrados = [];
  for (let i = 0; i < fuentes.length; i++) {
    for (let j = i + 1; j < fuentes.length; j++) {
      const a = ancestros(fuentes[i]), b = ancestros(fuentes[j]);
      a.add(fuentes[i]); b.add(fuentes[j]);
      for (const comun of a) if (b.has(comun)) encontrados.push({ comun, via: [fuentes[i], fuentes[j]] });
    }
  }
  return encontrados;
}

Ejecutado sobre el grafo de una aplicación real, este código devuelve muchísimos resultados. No es motivo de alarma: significa que el motor está haciendo su trabajo. Sí es útil para dos cosas: verificar que tu motor propio maneja bien la topología que de verdad produce tu código, y localizar el nodo de reunión cuando sospechas de un glitch concreto.

El rombo es el caso de prueba minimo de la correccion de un motor

Si tuvieras que escribir un único test para decidir si un motor reactivo es correcto, sería el rombo. Y la razón es que cualquier fallo de orden se manifiesta como un rombo. Piénsalo al revés: para que exista un problema de orden hace falta que un nodo pueda recibir información por dos caminos de longitudes distintas, y eso es la definición del rombo. Si no hay rombos, cualquier orden de propagación es correcto y el motor más ingenuo funciona; si hay rombos, solo funciona el que respeta la topología. De ahí sale una manera muy práctica de evaluar cualquier motor que te encuentres, incluido el tuyo: escribe el rombo, coloca un efecto en el punto de reunión que registre todos los valores que observa —no solo el último—, provoca un cambio en la raíz y cuenta. La respuesta correcta es exactamente un valor nuevo por escritura. Dos valores significan que hay glitch. Y todavía más útil: hazlo en el framework que uses a diario antes de creer lo que promete su documentación, porque la garantía glitch-free tiene matices en varios motores reales, sobre todo en la frontera entre computaciones puras y efectos. Es un test de cinco líneas que te dice más sobre un motor que su README entero.

Lo que hace falta para resolverlo

Adelantamos el resultado para que las tres lecciones siguientes tengan destino. Hay exactamente dos familias de solución.

Separar el marcado de la evaluación, que es lo que hace el modelo híbrido del nivel 4: se marca todo primero, y solo después se evalúa. Como en el momento de evaluar ya está todo marcado, el nodo de reunión resuelve sus dos fuentes antes de calcularse.

Ordenar la evaluación topológicamente, que consiste en asignar a cada nodo una profundidad y procesar en orden creciente. Es la solución clásica, la que usan los sistemas de flujo de datos con topología fija.

Las dos funcionan y tienen costes distintos. La lección 3 desarrolla la primera, la 4 la segunda, y la 5 las compara.

⚔️ Encuentra los rombos de tu aplicacion
  1. Instrumenta tu motor para registrar las aristas y vuelca el grafo de una pantalla real.
  2. Ejecuta el detector de rombos de esta lección sobre ese grafo.
  3. Ordena los resultados por diferencia de longitud entre los dos caminos y quédate con los mayores.
  4. Para el mayor, comprueba si el nodo de reunión es un efecto y qué pasaría si tu motor no garantizase el orden.