wandres.dev
PROBAR LA CONVERGENCIA · fuzzing y propiedades

La reducción de casos: de doscientas operaciones a las tres culpables

Un contraejemplo grande solo demuestra que hay un fallo; el valor entero de la técnica aparece cuando un reductor lo encoge hasta el mínimo que sigue fallando y el diagnóstico se vuelve inmediato.

⏱ 22 min

El generador acaba de encontrar una divergencia en una historia de doscientos eventos repartidos entre cuatro réplicas, con dos particiones y once entregas duplicadas. Enhorabuena: acabas de recibir la información menos accionable posible. Un contraejemplo de ese tamaño demuestra que existe un error y no ayuda en absoluto a encontrarlo, porque el trabajo de diagnóstico consiste en distinguir los tres eventos que provocan el fallo de los ciento noventa y siete que solo estaban de paso, y ese trabajo es exponencial si se hace a mano. La reducción automática es la pieza que convierte esta técnica de una curiosidad académica en una herramienta de ingeniería, y su idea cabe en una frase: probar sistemáticamente casos más pequeños y quedarse con los que siguen fallando, hasta que ningún recorte adicional falle. Lo interesante no es el algoritmo, que es trivial, sino todo lo que hay que garantizar para que funcione sobre historias concurrentes en lugar de sobre listas de números.

🎯 Al terminar esta lección sabrás
  • Entender por qué el tamaño del contraejemplo domina el coste del diagnóstico y no su rareza.
  • Implementar un reductor voraz con dos fases, borrado por bloques y simplificación de campos.
  • Reconocer los tres requisitos que la reducción impone al resto del sistema: determinismo, validez y monotonía.
  • Interpretar la noción de mínimo local y saber cuándo un contraejemplo reducido ya no se puede mejorar.

Por qué un contraejemplo grande no vale casi nada

El coste de depurar un fallo de convergencia no crece con la dificultad conceptual del error sino con el tamaño de la traza que hay que leer. Ante doscientos eventos, la pregunta que hay que responder es cuál de los subconjuntos posibles es el responsable, y el número de subconjuntos de doscientos elementos tiene sesenta y un dígitos. Nadie hace eso a ojo, así que lo que ocurre en la práctica es que alguien empieza a borrar eventos al azar durante media hora, se cansa y clasifica el fallo como intermitente.

Ante tres eventos, en cambio, el diagnóstico es inmediato y de una clase distinta: se lee la historia entera de un vistazo, se identifica la interacción y muy a menudo se reconoce una clase de error en lugar de una instancia. Dos añadidos concurrentes del mismo valor en réplicas distintas; una operación local y una entrega en el orden inverso al esperado; un borrado que precede a su propio añadido. Estos patrones tienen nombre en la literatura y la persona que los ve reducidos los reconoce, mientras que la misma persona no los reconocería enterrados en doscientos eventos.

ℹ️
La reducción no es una comodidad, es el paso que produce el conocimiento

Conviene resistir la lectura de la reducción como una mejora de ergonomía. Lo que hace el reductor es un experimento de aislamiento: al eliminar todo lo que no es necesario para reproducir el fallo, produce una explicación causal en lugar de una correlación. El contraejemplo mínimo no dice solo que el sistema falla, dice qué es lo mínimo suficiente para que falle, y esa segunda afirmación es la que se puede convertir en un arreglo, en una prueba fija y en una frase de una línea en el registro de cambios.

El algoritmo: quitar, simplificar, repetir

El reductor recibe un candidato que falla y una función que decide si un candidato sigue fallando, y devuelve un candidato mínimo. Su estructura es un descenso voraz: mientras algún recorte siga fallando, se adopta y se vuelve a empezar.

function reducir(historia, falla) {
  let actual = historia;
  let mejoro = true;
  while (mejoro) {
    mejoro = false;
    for (const candidato of candidatos(actual)) {
      if (falla(candidato)) { actual = candidato; mejoro = true; break; }
    }
  }
  return actual;
}

Toda la inteligencia está en candidatos, que enumera los recortes en el orden que más reduce por intento. La primera fase borra bloques contiguos empezando por los grandes y bajando por mitades, que es la idea del depurado por deltas: si medio guion se puede tirar de una vez, tirarlo cuesta un intento en lugar de cien. La segunda fase, cuando ya no se puede borrar nada, simplifica los campos numéricos de cada evento acercándolos a cero, lo que convierte índices y destinos arbitrarios en los más pequeños que aún reproducen el fallo.

function* candidatos(h) {
  // Fase 1: borrar bloques contiguos, de grandes a pequenos.
  for (let n = h.length; n >= 1; n = Math.floor(n / 2)) {
    for (let i = 0; i + n <= h.length; i += 1) {
      yield [...h.slice(0, i), ...h.slice(i + n)];
    }
    if (n === 1) break;
  }
  // Fase 2: acercar cada campo numerico a cero.
  for (let i = 0; i < h.length; i += 1) {
    const ev = h[i];
    for (const campo of ['indice', 'destino', 'replica']) {
      if (typeof ev[campo] === 'number' && ev[campo] > 0) {
        const copia = h.slice();
        copia[i] = { ...ev, [campo]: ev[campo] - 1 };
        yield copia;
      }
    }
    if (ev.op && ev.op.valor > 0) {
      const copia = h.slice();
      copia[i] = { ...ev, op: { ...ev.op, valor: ev.op.valor - 1 } };
      yield copia;
    }
  }
}

El orden de las dos fases importa y no es intercambiable. Borrar primero es lo que hace barato el proceso, porque cada borrado exitoso elimina de golpe todos los campos que habría habido que simplificar. Simplificar primero gastaría miles de intentos puliendo eventos que después van a desaparecer. La regla general es reducir siempre por la dimensión que más recorta el espacio restante, y en una historia esa dimensión es la longitud.

✂️

Borrado por bloques

Empezar por la mitad del guion y bajar por mitades convierte una reducción lineal en una logarítmica cuando el fallo depende de pocos eventos.

🔽

Simplificación de campos

Acercar los números a cero no acorta la historia pero la hace legible: réplica cero y réplica uno cuentan una historia que réplica siete y réplica tres no cuentan.

🔁

Reinicio tras cada éxito

Volver al principio del enumerador después de adoptar un candidato es lo que permite que un borrado tardío habilite borrados anteriores que antes fallaban.

🛑

Criterio de parada

Se termina cuando ningún candidato falla. El resultado es un mínimo local, no necesariamente el más pequeño posible, y esa distinción es real.

Reducir historias es más difícil que reducir listas

Sobre una lista de enteros, cualquier sublista es un candidato válido. Sobre una historia concurrente, no: quitar un evento puede producir un guion que el sistema no podría haber generado nunca, y entonces el reductor deja de estar buscando el mismo fallo. Los tres requisitos que la reducción impone al resto del sistema son la parte que se descubre tarde y duele.

El primero es el determinismo. La función que decide si un candidato falla debe dar siempre la misma respuesta para el mismo candidato. Si el simulador consulta el reloj, si la fusión ordena por un identificador aleatorio no sembrado o si el generador se invoca dentro del bucle de ejecución, el descenso voraz se convierte en un paseo aleatorio: adopta candidatos que no fallaban y descarta los que sí, y termina en cualquier sitio. Este es el motivo real por el que la lección anterior insistía en separar el guion de la ejecución.

El segundo es la validez. Un candidato debe seguir siendo una historia bien formada. Hay dos maneras de conseguirlo y conviene elegir a conciencia. La reparadora normaliza el candidato después de recortarlo, ajustando índices que ahora apuntan fuera de rango y descartando entregas sin mensaje disponible; es la vía del simulador de la lección anterior, que aplica el módulo a cada índice y así hace válido cualquier guion. La estructural genera candidatos que son válidos por construcción, lo que exige que el reductor entienda la semántica de la historia y es bastante más código.

flowchart TB
A[contraejemplo de 40 eventos] --> B[borrar bloque grande]
B --> C{sigue fallando}
C -->|si| D[adoptar y reiniciar]
C -->|no| E[probar bloque menor]
E --> C
D --> F{queda algun recorte que falle}
F -->|si| B
F -->|no| G[minimo local de 2 eventos]
style G fill:#a6e3a1,color:#11111b
style C fill:#89b4fa,color:#11111b

El tercero es la monotonía del criterio de parada, que evita que el reductor entre en un ciclo. Cada candidato adoptado debe ser estrictamente menor que el anterior según una medida bien fundada, típicamente el par formado por la longitud del guion y la suma de sus campos numéricos. Sin esa garantía, una simplificación que aumente un campo o un recorte que reordene eventos puede devolver al reductor a un estado ya visitado y dejarlo girando para siempre.

⚠️
Un reductor sobre un sistema no determinista no reduce: destruye información

El fallo más caro de esta pieza no es que el reductor no encuentre el mínimo, sino que devuelva un contraejemplo pequeño que ya no reproduce el fallo original. Ocurre siempre que el criterio es no determinista: el reductor adopta un candidato porque falló en esa ejecución concreta, sigue encogiéndolo y entrega al final una historia de tres eventos que falla una vez de cada veinte por una razón distinta de la original. El equipo arregla ese síntoma, cierra la incidencia y el error de verdad sigue ahí. Antes de confiar en un reductor hay que comprobar que el criterio, ejecutado cien veces sobre el mismo candidato, devuelve cien veces lo mismo.

De cuarenta eventos a dos

Un ejemplo concreto cierra el argumento. Tomemos un registro de última escritura mal implementado: guarda una marca lógica y un valor, y al fusionar se queda con el estado cuya marca es mayor o igual. Es un error clásico, porque cuando dos réplicas producen la misma marca con valores distintos, la fusión deja de ser conmutativa y el desempate por identificador de réplica, que es lo que faltaba, no existe.

const lww = {
  vacio: () => ({ t: 0, valor: null }),
  aplicar: (s, op) => ({ t: s.t + 1, valor: op.valor }),
  unir: (a, b) => (b.t >= a.t ? b : a),   // falta el desempate por replica
  iguales: (a, b) => a.t === b.t && a.valor === b.valor,
};

let encontrado = null;
for (let s = 0; s < 200 && !encontrado; s += 1) {
  const h = generar(motor(0xabc ^ s));
  if (!converge(h)) encontrado = h;
}

const minimo = reducir(encontrado, (h) => !converge(h));
console.log(encontrado.length, minimo.length);   // 40 2
console.log(JSON.stringify(minimo));
// [{"clase":"local","replica":0,"op":{"valor":0}},
//  {"clase":"local","replica":1,"op":{"valor":1}}]

El contraejemplo mínimo tiene dos eventos y no necesita comentario: dos réplicas ejecutan una operación local cada una, sin ninguna comunicación previa, y por tanto ambas llegan a la marca uno con valores distintos. En el reposo, cada réplica fusiona en su propio orden y la que fusiona en último lugar gana. La ausencia del desempate salta a la vista. Compárese con la utilidad de los cuarenta eventos originales, donde la misma situación estaba oculta entre dieciocho entregas y tres duplicados.

Conviene ser honesto sobre el límite del método. El resultado es un mínimo local: ningún candidato del enumerador falla, pero podría existir otro contraejemplo más pequeño estructuralmente distinto que el descenso voraz no alcanza. En el caso anterior el mínimo local coincide con el global porque dos eventos es lo mínimo imaginable, pero eso no es la norma. Un reductor que se detiene en seis eventos no está diciendo que no exista uno de tres: está diciendo que ninguno de los recortes que sabe proponer sigue fallando, y ampliar el enumerador con recortes nuevos es lo que empuja ese límite.

La reducción es lo que convierte un detector de fallos en un instrumento de conocimiento

Vale la pena situar esta pieza en el sitio que le corresponde, porque es la que decide si toda la inversión del nivel produce resultados o produce ruido. Un generador sin reductor es un detector: emite una alarma que dice que algo va mal y adjunta una traza que nadie va a leer entera. Un generador con reductor es un instrumento: emite un enunciado mínimo que casi siempre contiene su propia explicación. La diferencia entre ambas cosas no es de grado, es de naturaleza, y se aprecia en cómo se comporta el equipo ante la alarma. Frente a una traza de doscientos eventos, la respuesta humana previsible es aplazar, reintentar, marcar como intermitente y seguir; frente a un contraejemplo de dos eventos, la respuesta previsible es arreglarlo esa misma tarde, porque el coste de entenderlo ha bajado por debajo del coste de ignorarlo. Toda herramienta de calidad se usa o se abandona según esa comparación, y el reductor es precisamente lo que la inclina. Hay además una consecuencia menos evidente y más profunda sobre la naturaleza del conocimiento que produce. El contraejemplo mínimo tiene una propiedad de la que el grande carece: es generalizable. Cuando el fallo se reduce a dos operaciones concurrentes que producen la misma marca lógica, lo que queda a la vista no es un error de tu código sino la ausencia de una condición en tu diseño, y esa condición vale para todos los tipos de dato que uses el mismo esquema de marcas, incluidos los que todavía no has escrito. Un contraejemplo grande enseña algo sobre una ejecución; uno mínimo enseña algo sobre el algoritmo. Por eso el hábito que conviene instalar no es guardar el contraejemplo reducido como prueba de regresión, aunque también, sino leerlo buscando el enunciado que lo explica y escribir ese enunciado en el código como una invariante o como una propiedad nueva. La reducción es, en ese sentido, un procedimiento mecánico que produce hipótesis científicas: encoge una observación hasta que se vuelve una ley, y ese es un servicio que ninguna otra herramienta de tu caja te presta.

⚔️ Reduce tu primer contraejemplo hasta el hueso
  1. Implementa el reductor de dos fases sobre las historias que genera tu simulador y verifica que el criterio de fallo es determinista ejecutándolo cien veces.
  2. Introduce el registro de última escritura sin desempate, busca un contraejemplo y anota su longitud original.
  3. Ejecútale el reductor y anota la longitud final, el número de intentos y el tiempo empleado.
  4. Invierte el orden de las dos fases del enumerador y compara el número de intentos con el del paso anterior.
  5. Añade al enumerador un recorte nuevo, como fusionar dos operaciones consecutivas de la misma réplica, y comprueba si el mínimo local baja.
  6. Toma el contraejemplo mínimo y escribe en una frase la invariante que faltaba; conviértela en una propiedad y añádela a la suite.