wandres.dev
FUGUE · no intercalación maximal

Por qué resultó fácil de optimizar: tramos, no caracteres

La formulación de Fugue hace que el tecleo normal produzca cadenas de hijos únicos, y esas cadenas se colapsan en un solo objeto por tramo, algo que las familias de identificadores densos no podían permitirse.

⏱ 20 min

Un artículo que propone una propiedad de corrección nueva tiene que responder antes o después a la objeción práctica: ¿cuánto cuesta? La respuesta de Fugue es inusualmente buena, y lo interesante no es la cifra sino de dónde sale. No hay ningún truco de compresión propio ni ninguna estructura ingeniosa: las optimizaciones están tomadas de las que ya usaban Yjs y la variante segmentada de RGA, y son las mismas que cualquiera aplicaría. Lo que cambia es que la formulación de Fugue admite esas optimizaciones sin que la semántica se resienta, y esa admisibilidad no es un accidente afortunado. La escritura normal, de izquierda a derecha, produce en el árbol de Fugue una cadena en la que cada nodo es el único hijo derecho del anterior, y una cadena de hijos únicos está completamente determinada por su cabeza y su contenido. Guardar un objeto por carácter en ese caso es guardar la misma información cientos de veces. Colapsarla en un objeto por tramo deja intacta la forma del árbol, y como la propiedad de no intercalación es una propiedad de la forma del árbol, no hay nada que volver a demostrar. Esa es toda la historia, y su resultado son metadatos que ocupan el sesenta por ciento del texto que describen.

🎯 Al terminar esta lección sabrás
  • Reconocer por qué el tecleo hacia delante genera cadenas de hijos únicos en el árbol de Fugue.
  • Entender el colapso de una cadena en un punto de referencia único y qué se conserva al hacerlo.
  • Leer los números de la evaluación sobre una traza real y saber qué mide cada uno.
  • Explicar por qué las familias de identificadores densos no podían aplicar la misma compresión.

La cadena que produce el tecleo normal

Vuelve un momento a la regla de colocación de la lección anterior. Al insertar detrás de un elemento que todavía no tiene hijos derechos, el nodo nuevo se cuelga como hijo derecho de ese elemento. Al teclear una palabra de izquierda a derecha, cada carácter se inserta detrás del anterior, que acaba de nacer y por tanto no tiene ningún hijo derecho todavía. La consecuencia es inevitable y es la clave de todo lo que sigue: una ráfaga de tecleo hacia delante produce una cadena descendente en la que cada nodo es el único hijo derecho de su padre.

Una cadena así no contiene apenas información. El identificador del segundo nodo, del tercero y del centésimo son derivables del identificador de la cabeza y de la posición dentro del tramo, porque los identificadores se asignan secuencialmente dentro de una misma réplica. El padre de cada uno es el anterior. El lado de cada uno es el derecho. Lo único genuinamente distinto entre ellos es el valor. Guardar cien objetos con cien punteros a padre para representar cien caracteres consecutivos es, literalmente, almacenar noventa y nueve veces un dato que ya se conocía.

El colapso consiste en sustituir toda la cadena por un solo objeto, que el artículo llama punto de referencia, y que guarda el identificador de la cabeza, la secuencia de valores y la longitud. Cuando alguien inserta concurrentemente en medio del tramo, el punto de referencia se parte en dos y aparece una bifurcación real, que sí hay que representar. El número de objetos deja entonces de crecer con el número de caracteres y pasa a crecer con el número de puntos de bifurcación, que en un texto escrito por personas es una fracción muy pequeña del total.

Merece la pena razonar por qué esa fracción es tan pequeña, porque es una propiedad de cómo escriben las personas y no del algoritmo. Un párrafo de doscientos caracteres tecleado de corrido es un solo tramo. Volver atrás a corregir una palabra parte ese tramo en dos y crea uno nuevo, de modo que una sesión de escritura con muchas correcciones genera más objetos que una de escritura fluida. Una edición concurrente de otra persona en mitad del párrafo hace lo mismo. Pero incluso en la traza real que se usa para medir, que recoge cada pulsación del proceso completo de escritura de un artículo —con sus correcciones, sus reescrituras y sus borrados—, la razón entre caracteres y objetos sigue siendo lo bastante alta como para que los metadatos ocupen menos que el texto. El colapso no es una apuesta optimista sobre un caso ideal: es una apuesta sobre el comportamiento humano, y la traza demuestra que la apuesta se gana.

flowchart TD
A[tecleo de izquierda a derecha] --> B[cada caracter es el unico hijo derecho del anterior]
B --> C[la cadena queda determinada por el id de cabeza y los valores]
C --> D[un objeto por tramo en vez de un objeto por caracter]
B --> E[una insercion concurrente en medio parte el tramo en dos]
E --> F[el numero de objetos crece con las bifurcaciones reales]
D --> G[168 kB guardados para 105 kB de texto plano]
D --> H[2.4 MB en memoria o 23 bytes por caracter]
style D fill:#a6e3a1,color:#11111b
style G fill:#89b4fa,color:#11111b
style H fill:#89b4fa,color:#11111b

Puesto en código, el objeto que sustituye a la cadena es notablemente pobre, y esa pobreza es la medida exacta de cuánta redundancia había antes.

// Un punto de referencia sustituye a toda una cadena de hijos unicos.
// En lugar de un objeto por caracter, un objeto por tramo.
const tramo = {
  idCabeza: "r7:1042",       // identificador del primer nodo de la cadena
  padre: "r3:88",            // padre de la cabeza
  lado: "derecha",           // lado por el que cuelga la cabeza
  valores: "para siempre",   // los trece caracteres consecutivos
};

// Los identificadores interiores no se almacenan: se derivan del offset,
// porque cada replica los asigna de forma secuencial.
function idEnOffset(tramo, offset) {
  const [replica, contador] = tramo.idCabeza.split(":");
  return `${replica}:${Number(contador) + offset}`;
}

// Una insercion concurrente en mitad del tramo lo parte en dos.
// Ahi si aparece una bifurcacion real que ocupa objetos nuevos.
function partir(tramo, offset) {
  return [
    { ...tramo, valores: tramo.valores.slice(0, offset) },
    {
      idCabeza: idEnOffset(tramo, offset),
      padre: idEnOffset(tramo, offset - 1),
      lado: "derecha",
      valores: tramo.valores.slice(offset),
    },
  ];
}

Obsérvese que la función que parte un tramo no consulta ninguna política de fusión, no compara marcas de tiempo y no decide nada: solo reorganiza objetos preservando exactamente la misma relación de parentesco. Esa es la prueba operativa de que la compresión es una transformación de representación y no de semántica. Si al partir un tramo hiciera falta tomar alguna decisión sobre el orden, la compresión estaría tocando el comportamiento observable y habría que rehacer las demostraciones.

ℹ️
La compresión no es una idea nueva, la compatibilidad sí

El artículo es explícito al respecto y conviene no atribuirle un mérito que no reclama: las optimizaciones están inspiradas en Yjs y en la variante segmentada de RGA, y agrupar inserciones consecutivas era una técnica conocida. Lo que aporta Fugue no es la técnica sino la garantía de que aplicarla no cambia nada. De hecho la sección que presenta el algoritmo omite deliberadamente toda compresión y advierte que puede añadirse después sin afectar al núcleo. Esa frase, que parece una nota de organización del texto, es la afirmación de ingeniería más importante del artículo: semántica y representación están separadas, y por eso el trabajo de optimizar no puede romper la demostración.

Los números sobre una traza real

La evaluación no usa cargas sintéticas. Reproduce una traza real de edición en la que se capturó cada pulsación del proceso de escritura del código fuente de un artículo de diecisiete páginas: 182.315 inserciones de un carácter y 77.463 borrados de un carácter, con un documento final de 104.852 caracteres sin contar lápidas. El texto plano ocupa 105 kB. Se midieron tres implementaciones propias y tres bibliotecas del ecosistema, con cinco ejecuciones de calentamiento y diez de medida.

DOCUMENTO GUARDADO   texto plano de referencia 105 kB

  implementacion        tamano      guardar     cargar
  Fugue                 168 kB       20 ms       13 ms
  Fugue simple        1.021 kB      583 ms      334 ms
  FugueMax simple     1.237 kB      788 ms      522 ms
  Automerge-Wasm        129 kB      180 ms    2.746 ms
  Yjs                   160 kB       17 ms       63 ms
  Y-Wasm                160 kB        5 ms       15 ms

USO EN VIVO POR UN SOLO USUARIO

  implementacion       memoria    bytes por op   ops por segundo
  Fugue                 2,4 MB         46            94.000
  Fugue simple         64,8 MB        151            17.000
  FugueMax simple      71,9 MB        188            16.000
  Automerge-Wasm            -         126            52.000
  Yjs                   3,3 MB         29            39.000

TRAZA REPETIDA CIEN VECES   10,5 millones de caracteres

  Fugue    17.845 kB guardados   1.405 ms guardar    640 ms cargar   223 MB
  Yjs      15.989 kB guardados     479 ms guardar  2.316 ms cargar   294 MB

Antes de interpretar las cifras conviene saber qué mide cada columna, porque cada una corresponde a un momento distinto de la vida de una aplicación local-first y no son intercambiables. El tamaño guardado determina el espacio en disco y el tráfico cuando la sesión se cierra y se vuelve a abrir. El tiempo de carga es latencia de arranque percibida directamente por el usuario, y es la métrica que más suele descuidarse. La memoria aproxima el peso de la pestaña mientras el documento está abierto. Los bytes por operación miden lo que viaja por la red en cada pulsación. Y las operaciones por segundo miden el margen disponible antes de que el modelo se convierta en el cuello de botella. Todo se midió en un mismo entorno modesto y descrito con detalle —Node.js sobre una máquina de cuatro núcleos y dieciséis gigabytes— con cinco ejecuciones de calentamiento y diez de medida, lo que permite comparar sin discutir sobre el banco de pruebas.

Tres lecturas que importan más que las cifras concretas. La primera: los metadatos del CRDT ocupan solo el sesenta por ciento del texto literal, o veintitrés bytes por carácter en memoria, o trece bytes si se cuentan también los caracteres borrados que siguen presentes como lápidas. Eso refuta directamente la crítica más repetida contra los CRDT para texto, que es que su sobrecarga por carácter los hace inviables. La segunda: el rendimiento en vivo está tan lejos de ser un cuello de botella que la comparación es casi cómica —noventa y cuatro mil operaciones por segundo son unos once microsegundos por operación, mientras que una persona teclea alrededor de diez caracteres por segundo y un documento colaborativo típico tiene menos de cien participantes simultáneos—. La tercera: el tamaño guardado incluso disminuye cuando se borra texto, a pesar de las lápidas, porque el colapso en tramos también agrupa los borrados consecutivos.

Hay una columna que en un contexto local-first merece más atención de la que suele recibir, y es el tiempo de carga. Trece milisegundos frente a dos mil setecientos cuarenta y seis es una diferencia de dos órdenes de magnitud, y no se manifiesta como una cifra en una tabla sino como la sensación de que la aplicación arranca o no arranca. En una arquitectura donde la copia local es la fuente de verdad, cada apertura del documento es una reconstrucción del estado a partir de lo guardado, de modo que esa métrica se paga en cada sesión y en cada pestaña. Es exactamente el problema del arranque en frío, y conviene evaluarla con al menos tanto cuidado como el tamaño en disco, que es lo que casi todo el mundo compara primero.

La última tabla estira la prueba hasta un extremo deliberadamente absurdo: la misma traza repetida cien veces, un documento final de diez millones y medio de caracteres, varias veces más largo que Guerra y paz. El resultado no es que el algoritmo brille, sino algo más útil: sigue siendo tolerable. Diecisiete megabytes guardados, menos de un segundo y medio para guardar, poco más de medio segundo para cargar y doscientos veintitrés megabytes de memoria. Ningún editor colaborativo real trabaja con documentos así, y ese es justamente el sentido de la medición: establecer que el techo está muy por encima de cualquier caso de uso plausible, de modo que las decisiones de producto puedan tomarse sin pensar en él.

💡
Compara siempre la versión ingenua con la optimizada, no solo con los rivales

La tabla contiene una comparación que suele omitirse y que es la más instructiva de todas: la implementación directa del algoritmo, en 298 líneas, con un objeto por nodo y JSON comprimido, frente a la implementación optimizada, en 1.132 líneas, con tramos y codificación binaria. Un megabyte frente a 168 kB. Sesenta y cinco megabytes de memoria frente a dos y medio. Seiscientos dieciocho bytes por carácter frente a veintitrés. El mismo algoritmo, la misma semántica, las mismas garantías. Si alguna vez descartas una estructura por sus números, asegúrate de saber cuál de las dos versiones estabas midiendo, porque el factor entre ambas es de más de veinte y no tiene nada que ver con el algoritmo.

Lo que costaba antes y por qué

La pregunta que queda es por qué esto era difícil en las estructuras anteriores, y la respuesta separa nítidamente dos maneras de identificar la posición de un elemento en una lista replicada.

🎼

Identificador relativo, como en Fugue

La posición se expresa como padre más lado. Los caracteres consecutivos comparten toda la información salvo el valor, así que un tramo entero cabe en un objeto.

🔢

Identificador denso, como en Logoot o LSEQ

La posición es un racional o un camino que debe caer estrictamente entre sus vecinos. Cada carácter necesita su propio identificador y ninguno se deduce del anterior.

📈

El crecimiento del identificador

En las familias densas, insertar repetidamente en el mismo hueco alarga los identificadores, de modo que el coste por carácter no es constante sino creciente con la historia del documento.

🧾

El coste de los vecinos explícitos

En WOOT cada carácter guarda los identificadores de sus dos vecinos originales, lo que multiplica los metadatos y tampoco se deja agrupar en tramos.

Hay un matiz que conviene precisar para no exagerar el argumento. RGA sí admite agrupación en tramos —de hecho la variante segmentada de RGA es una de las inspiraciones declaradas de la implementación de Fugue— y Yjs también. La diferencia no es que Fugue inventara la compresión, sino que consigue la no intercalación maximal sin renunciar a ella. Ese es el equilibrio que no se había alcanzado antes: los algoritmos que comprimían bien intercalaban, y el algoritmo que en 2019 intentó no intercalar ni siquiera convergía. Fugue ocupa por primera vez las dos casillas a la vez.

Queda por señalar el precio concreto de la maximalidad, porque el artículo no lo esconde. FugueMax necesita anotar en cada hijo derecho su origen derecho, y eso se nota: en las implementaciones directas, el documento guardado de FugueMax ocupa 1.237 kB frente a los 1.021 kB de Fugue, y su rendimiento en vivo es algo peor. Por eso la implementación optimizada que se publicó y que adoptó la biblioteca Collabs en su versión 0.6.1 es la de Fugue, no la de FugueMax: Fugue se queda solo ligeramente por debajo de la propiedad y las ejecuciones en que ambos difieren son raras. Es una elección de ingeniería perfectamente defendible precisamente porque la distancia entre ambos está caracterizada y demostrada, no estimada.

Una semántica definida por la forma es una semántica que se puede comprimir

Detrás de la facilidad con que Fugue se optimiza hay un principio que va mucho más allá de este algoritmo y que explica, retrospectivamente, buena parte de la historia de los CRDT de listas. Cuando la semántica de una estructura está definida por la forma de una relación —quién es padre de quién, por qué lado cuelga— la representación queda libre: cualquier codificación que preserve esa relación produce exactamente el mismo comportamiento observable, y por tanto se puede comprimir, reordenar en memoria, serializar en binario o agrupar en tramos sin tocar ninguna demostración. Cuando en cambio la semántica está definida por el valor de un identificador —el orden de la lista es el orden de los identificadores— la representación queda atada a la semántica de manera irrompible: el identificador es el significado, no se puede acortar sin cambiar el orden, y comprimir dos elementos consecutivos en uno solo destruiría la capacidad de insertar entre ellos. Esa es la razón profunda de que las familias de identificadores densos hayan resultado tan difíciles de hacer eficientes durante quince años, y no un problema de ingeniería que a nadie se le ocurriera resolver. El corolario es una regla de diseño que conviene aplicar mucho antes de tocar código: decide pronto si el significado de tus datos vive en la topología o en los valores, porque esa decisión fija de antemano cuánto podrás optimizar después. Si vive en la topología, tienes toda la libertad de representación y la optimización es un problema separable que puede abordarse años más tarde por gente distinta, que es exactamente lo que ocurrió aquí: el artículo presenta el algoritmo sin ninguna compresión y anuncia que puede añadirse después sin afectar al núcleo, y esa frase resultó ser literalmente cierta. Si vive en los valores, cada optimización es una renegociación de la semántica y cada renegociación exige rehacer las demostraciones, con lo que en la práctica no se hacen. La lección para cualquier sistema local-first que diseñes es la misma: la capa que define el significado y la capa que define los bytes deberían poder evolucionar por separado, y si no pueden, ya sabes cuál va a ser el techo de tu rendimiento dentro de tres años.

⚔️ Mide el colapso en tramos
  1. Implementa el árbol con un objeto por nodo y mide bytes por carácter sobre una traza de tecleo tuya de al menos diez mil pulsaciones.
  2. Añade el colapso en tramos para las cadenas de hijos únicos por el lado derecho y vuelve a medir; anota el factor de mejora.
  3. Extiende el colapso al lado izquierdo, que corresponde a la anteposición repetida, y comprueba cuánto aporta en una traza con muchas anteposiciones.
  4. Introduce inserciones concurrentes en mitad de un tramo y observa cómo el número de objetos crece con las bifurcaciones y no con los caracteres.
  5. Reemplaza la codificación JSON por una binaria con esquema y separa en la medida cuánto viene del colapso y cuánto de la codificación.
  6. Implementa la anotación de origen derecho que exige FugueMax y cuantifica su sobrecoste real sobre tu propia traza.