wandres.dev
DIRECCIONAR POR CONTENIDO II · Merkle DAG y CID

La propiedad clave: la raíz resume el grafo entero

Si dos raíces coinciden, todo lo que cuelga de ellas coincide, y esa implicación permite comparar dos estructuras enormes con una sola comparación de treinta y dos bytes.

⏱ 20 min

La lección anterior estableció la regla y sus consecuencias inmediatas, pero dejó sin explotar la que convierte al Merkle DAG en una herramienta de sistemas distribuidos y no en una curiosidad de teoría de estructuras. La regla dice que el nombre de un nodo depende de los nombres de sus hijos; aplicada hacia arriba y de forma reiterada, dice que el nombre de la raíz depende de absolutamente todo lo alcanzable desde ella. Eso convierte un identificador de tamaño fijo en un resumen fiel de una estructura de tamaño arbitrario, y transforma la pregunta más cara de cualquier sistema replicado —tenemos los dos lo mismo— en una comparación de treinta y dos bytes que puede hacerse por una red lenta, en un mensaje diminuto y sin leer un solo byte de los datos. Y cuando la respuesta es que no, la misma estructura ofrece un procedimiento de descenso que localiza la diferencia pagando en proporción a la diferencia y no en proporción al tamaño. Esta lección demuestra la propiedad, la explota y luego mide con precisión hasta dónde llega.

🎯 Al terminar esta lección sabrás
  • Formular la propiedad de resumen y entender por qué se sigue por inducción de la resistencia a colisiones.
  • Convertir una comparación de estructuras de coste lineal en una comparación de coste constante.
  • Manejar el descenso comparativo que localiza diferencias pagando en proporción a lo que difiere.
  • Delimitar qué garantiza la igualdad de raíces y qué no garantiza en absoluto.

De la regla local a la propiedad global

El enunciado que interesa es una implicación en un solo sentido y conviene decirlo así de crudo: si el identificador de dos nodos coincide, entonces los subgrafos completos que cuelgan de ambos son idénticos nodo a nodo. La dirección contraria es trivial, porque el identificador es una función determinista del contenido, y por eso suele darse por supuesta y no aporta nada. Lo valioso es la primera dirección, porque permite concluir algo sobre una estructura enorme a partir de un dato minúsculo.

La demostración es una inducción sobre la profundidad y cabe en un párrafo. En el caso base, un nodo sin hijos: si los hashes coinciden, o los contenidos coinciden o alguien ha encontrado una colisión en la función. En el paso inductivo, un nodo con hijos: si los hashes coinciden, coinciden los cuerpos serializados salvo colisión, y el cuerpo contiene tanto los datos propios como la lista de identificadores de los hijos; por hipótesis de inducción, identificadores iguales en los hijos implican subgrafos iguales en los hijos. La conclusión se propaga hacia abajo hasta las hojas.

Conviene subrayar la cláusula que aparece dos veces en ese razonamiento, porque define la naturaleza exacta de la garantía. La igualdad no es una certeza lógica sino una certeza computacional: es cierta salvo que exista una colisión, y la resistencia a colisiones de la función es lo único que impide construirla. Es la misma clase de garantía sobre la que se apoyan las firmas digitales y el control de versiones, y sostiene sistemas enteros sin dramatismo, pero tiene fecha de caducidad ligada a la vida útil de la función elegida.

Que cuesta responder a la pregunta tenemos los dos lo mismo

  comparar campo a campo ....... proporcional al tamano de los datos
  comparar marcas de tiempo .... barato y poco fiable, depende de relojes
  comparar numeros de version .. barato y solo vale con un contador central
  comparar raices de Merkle .... constante, y fiable como comparar byte a byte

Merece la pena detenerse en la tercera línea de esa tabla, porque es la alternativa que más se usa y la que peor envejece. Un número de versión funciona mientras alguien lo asigne de forma centralizada; en cuanto dos réplicas pueden avanzar por su cuenta, el contador deja de ordenar nada y hay que sustituirlo por un vector, con el coste de metadatos que el track ya estudió. La raíz de Merkle no es un contador y no pretende ordenar: se limita a decir si dos estados son el mismo, que es una pregunta más débil y, precisamente por eso, contestable sin coordinación de ningún tipo.

ℹ️
El coste de la comparación es constante y no depende de nada

Merece la pena aislar la cifra porque es contraintuitiva. Comparar dos árboles de directorios con diez millones de ficheros, dos bases de datos replicadas o dos historiales de documento con años de ediciones cuesta exactamente lo mismo que comparar dos ficheros vacíos: leer treinta y dos bytes de cada lado y ver si son iguales. No hay recorrido, no hay lectura de datos, no hay tamaño que entre en la cuenta. La comparación se vuelve independiente de aquello que compara, y ese desacoplamiento es lo que permite que un dispositivo móvil verifique en un mensaje que está al día respecto a un servidor con terabytes.

Comparar dos estructuras enormes con un solo intercambio

La aplicación directa es el saludo de sincronización. Dos réplicas que quieren saber si divergen se envían mutuamente el identificador de su raíz. Si coinciden, han terminado: el protocolo concluye con la certeza de que sus estados son idénticos hasta el último byte, habiendo transferido unas pocas decenas de bytes. Este caso, que parece anecdótico, es en realidad el caso mayoritario en producción, porque la inmensa mayoría de las comprobaciones periódicas de un sistema replicado devuelven que no hay nada que hacer.

// El saludo completo cuando no hay divergencia
async function estamosAlDia(remoto) {
  const mia = await raizLocal();
  const suya = await remoto.pedirRaiz();
  return mia === suya;   // dos identificadores, ninguna lectura de datos
}

Hay un detalle de ese saludo que conviene no perder de vista, porque explica por qué la técnica funciona bien en redes hostiles. El resultado no depende de que ambos extremos sean honestos ni de que el canal sea fiable: si el remoto miente sobre su raíz, lo único que consigue es que el descenso posterior no cuadre y falle la verificación de algún bloque; si el mensaje llega corrupto, la comparación da distinto y se repite. Ningún fallo del canal ni del interlocutor puede producir un falso positivo de igualdad, que es la respuesta peligrosa, porque para fabricarla haría falta una colisión.

El contraste con las alternativas convencionales es lo que da la medida del ahorro. Comparar por marcas de tiempo obliga a recorrer la estructura entera y además es poco fiable, porque los relojes mienten y una copia puede tener fecha nueva con contenido idéntico. Comparar por tamaños es más barato y más frágil todavía. Comparar campo a campo es fiable y cuesta proporcional al tamaño de los datos, además de exigir que ambos lados estén dispuestos a transmitirlos. La raíz de un Merkle DAG da la fiabilidad de la última opción al precio de la primera.

El descenso: localizar la diferencia pagando por la diferencia

Cuando las raíces no coinciden, la estructura ofrece algo mejor que un simple no. Basta descender un nivel: se piden los dos bloques raíz, se comparan sus listas de identificadores de hijos y se anotan las posiciones donde difieren. Los subgrafos cuyos identificadores coinciden se descartan enteros, sin mirarlos, con la garantía de la propiedad anterior. Solo se recurre sobre las ramas discrepantes, y así hasta llegar a las hojas que efectivamente cambiaron.

// Descenso comparativo: solo se abre lo que difiere
async function diferencias(idA, idB, ruta = []) {
  if (idA === idB) return [];                     // rama identica, se poda entera
  const [a, b] = await Promise.all([leer(idA), leer(idB)]);
  if (esHoja(a) || esHoja(b)) return [{ ruta, idA, idB }];
  const salida = [];
  for (const clave of union(a.enlaces, b.enlaces)) {
    salida.push(...await diferencias(a.enlaces[clave], b.enlaces[clave], [...ruta, clave]));
  }
  return salida;
}
flowchart TB
RA[raiz local] --> A1[rama uno identidad igual]
RA --> A2[rama dos identidad distinta]
RB[raiz remota] --> A1
RB --> B2[rama dos version remota]
A2 --> H1[hoja cambiada]
B2 --> H2[hoja cambiada remota]
style A1 fill:#a6e3a1,color:#11111b
style H1 fill:#f38ba8,color:#11111b
style H2 fill:#f38ba8,color:#11111b

La cuenta del coste de ese descenso es la razón por la que esta técnica sostiene protocolos de antientropía en sistemas reales. Si en un grafo equilibrado de un millón de hojas ha cambiado una sola, el descenso abre un bloque por nivel a cada lado, alrededor de veinte bloques en total, y descarta el resto sin leerlo. El número de rondas es la profundidad y el número de bytes transferidos es proporcional al número de nodos en el camino, no al tamaño de la estructura. Cuando lo que ha cambiado son mil hojas dispersas, el coste sube a los caminos de esas mil hojas, que comparten los niveles altos y por tanto no se suman de forma independiente.

Descenso sobre un grafo de un millon de hojas y aridad treinta y dos

  hojas cambiadas ....... 1        bloques abiertos ....... unos 8 por lado
  hojas cambiadas ....... 100      bloques abiertos ....... unos 300 por lado
  hojas cambiadas ....... 100 000  bloques abiertos ....... casi todo el grafo

  el metodo gana cuando la divergencia es pequena y se degrada suavemente

La última fila del cuadro dice algo que conviene aceptar sin resistencia: cuando la divergencia es masiva, el descenso no ahorra nada y además paga muchas rondas de red para descubrirlo. Un protocolo maduro detecta ese caso pronto —por ejemplo, si en los dos primeros niveles difieren casi todos los enlaces— y cambia de estrategia, transfiriendo el subárbol entero de una vez en lugar de seguir preguntando. El descenso es una optimización para el caso frecuente, no un algoritmo universal, y tratarlo como lo segundo produce sincronizaciones iniciales lentísimas.

🤝

Igualdad en un mensaje

Dos identificadores bastan para certificar que dos réplicas coinciden por completo, sin transferir ni leer datos.

✂️

Poda de subgrafos idénticos

Cada rama con identificador coincidente se descarta entera, y esa poda es exacta, no heurística.

📉

Coste según la diferencia

El trabajo escala con lo que difiere y con la profundidad, no con el tamaño total de lo que se compara.

🧭

La ruta como resultado

El descenso no solo dice que hay diferencia: devuelve la ruta exacta hasta cada hoja discrepante.

La segunda tarjeta merece una precisión, porque la palabra poda se usa a menudo para heurísticas que pueden equivocarse. Aquí no hay heurística ni margen de error tolerado: descartar una rama porque su identificador coincide es una deducción, con la misma fuerza que la demostración por inducción del principio de la lección. Esa exactitud es la que permite construir sincronizadores que no necesitan una fase de verificación posterior, y la que diferencia este mecanismo de los filtros probabilísticos que también se usan para comparar conjuntos y que sí admiten falsos positivos.

La cuarta tarjeta apunta a un beneficio que se aprovecha poco. El descenso produce, además del conjunto de bloques que faltan, la ruta lógica hasta cada uno: qué directorio, qué campo, qué índice. Eso convierte un mecanismo de sincronización en un mecanismo de diagnóstico, capaz de explicar en términos del dominio qué es lo que ha divergido, y no solo de arreglarlo en silencio.

⚠️
El equilibrio del grafo decide si el descenso es barato o inútil

Todas las cifras anteriores suponen un grafo razonablemente equilibrado y con aridad alta. Si la estructura degenera en una lista —un nodo con un hijo, que tiene un hijo, que tiene un hijo—, la profundidad pasa a ser lineal y el descenso deja de ser logarítmico para convertirse en un recorrido completo con muchas rondas de red, que es el peor de los mundos. Y si la aridad es demasiado alta, cada bloque intermedio se vuelve grande y una sola diferencia obliga a transferir una lista enorme de identificadores para encontrarla. La forma del grafo no es un detalle interno: es el parámetro que determina si esta propiedad rinde o no rinde, y elegirla mal anula el beneficio entero sin que ningún test de corrección lo detecte.

Los límites exactos de la garantía

La propiedad es fuerte y por eso conviene acotar con precisión qué no dice, porque los errores de diseño más caros vienen de extenderla más allá de su alcance. Lo primero: la igualdad de raíces es igualdad de bytes serializados, no equivalencia semántica. Dos réplicas que contienen la misma información lógica pero la serializan con las claves en distinto orden, o con un entero codificado de dos maneras válidas, producirán raíces distintas y el protocolo concluirá que divergen. La canonicidad de la codificación es, por tanto, precondición de todo lo anterior.

Lo segundo, y más importante: la raíz no dice nada sobre el tiempo. Un identificador antiguo es tan válido y tan verificable como uno reciente, y no hay nada dentro del grafo que permita saber cuál de dos raíces distintas es posterior. Cuando el descenso encuentra una discrepancia, la estructura te dice dónde difieren, jamás cuál tiene razón. Esa pregunta pertenece a los relojes lógicos y a los CRDT de los niveles anteriores, o a una firma que ordene las raíces por fuera, y ninguna cantidad de hashing la responderá.

Lo tercero es una asimetría que se olvida a menudo: descubrir qué falta no es lo mismo que obtenerlo. El descenso identifica los bloques ausentes por su nombre y termina ahí. Que alguien tenga esos bytes y esté dispuesto a servirlos es un problema de la capa de red, y la lección final del nivel volverá sobre esa frontera. Un protocolo que confunda ambas cosas acabará con un conjunto perfecto de identificadores irresolubles.

Y lo cuarto es una fuga de información que conviene tener presente en escenarios adversarios. Participar en un descenso revela al otro extremo la forma de tu grafo: qué ramas coinciden con las suyas y cuáles no, con la granularidad que permita la estructura. Si los identificadores se derivan de contenido que el otro puede adivinar, el protocolo se convierte en un oráculo que confirma o desmiente hipótesis sobre lo que guardas. En despliegues donde eso importa, la respuesta habitual es cifrar antes de trocear, con el coste en deduplicación que eso conlleva y que la tercera lección ya adelantó.

Un resumen de tamaño fijo convierte la comparación en una constante, y eso reorganiza el protocolo entero

Si de esta lección hay que retener una sola idea, es que la propiedad de resumen no es un truco de eficiencia sino un cambio en la economía de la sincronización, y ese cambio se ve mejor mirando lo que hacía falta antes. En un sistema sin resúmenes verificables, averiguar si dos réplicas coinciden exige una de estas tres cosas: transferir los datos y compararlos, lo que cuesta en proporción al tamaño; confiar en metadatos como marcas de tiempo o números de versión, lo que traslada la corrección a relojes y contadores que mienten; o mantener un registro de operaciones que ambos lados vayan consumiendo, lo que obliga a conservar historia y a coordinar su poda. El resumen de Merkle elimina el compromiso entre coste y fiabilidad que hay detrás de esas tres opciones, porque ofrece la fiabilidad de comparar byte a byte al coste de comparar dos palabras. Y la reorganización que provoca va mucho más allá del ahorro de tráfico: como la comparación es constante, se puede hacer todo el rato, en cada latido, sin pensárselo dos veces, lo que sustituye la sincronización basada en eventos —que se pierde mensajes, que se rompe con particiones, que necesita reintentos y confirmaciones— por una sincronización basada en convergencia de estado que se autocorrige por el mero hecho de repetirse. Un dispositivo que estuvo apagado seis meses no necesita reconstruir la secuencia de lo ocurrido: compara raíces, desciende por lo que difiere y termina, y el resultado no depende de que ningún mensaje intermedio se recibiera. Ahí está la lección transferible, que vale más allá de estos grafos: cuando puedas resumir un estado grande en un valor pequeño que sea función fiel de todo él, habrás convertido un protocolo con memoria en un protocolo sin ella, y los protocolos sin memoria son los únicos que sobreviven de verdad a redes que pierden paquetes, a dispositivos que desaparecen y a implementaciones que reinician en el peor momento. El precio, ya dicho, es que el resumen es fiel al contenido y ciego al tiempo, de modo que la pregunta de quién va por delante hay que resolverla en otro sitio y con otras herramientas.

⚔️ Mide el descenso sobre una estructura real
  1. Construye un Merkle DAG sobre un directorio con al menos mil ficheros y anota el identificador de la raíz.
  2. Duplica el directorio, cambia un solo byte de un fichero profundo y calcula la nueva raíz.
  3. Implementa el descenso comparativo y cuenta cuántos bloques abre para localizar esa diferencia.
  4. Repite con cien ficheros modificados y comprueba que el coste no es cien veces el anterior, y explica por qué.
  5. Degenera a propósito la estructura en una lista de aridad uno y vuelve a medir; compara rondas de red y bloques leídos.
  6. Cambia el orden de las entradas de un directorio sin tocar ningún fichero y observa que el protocolo declara divergencia.