Tienes cinco implementaciones de la misma función. Tres devuelven el mismo resultado. Una es ligeramente diferente. Una lanza una excepción. ¿Cuál es la correcta?

La mayoría de los equipos recurre por defecto al voto por mayoría. Eso funciona bien cuando las salidas son idénticas y los errores son evidentes. Se desmorona en el momento en que tus implementaciones discrepan de forma sutil, o cuando cada variante devuelve una respuesta diferente. La programación de N versiones solo resuelve la primera mitad del problema: ejecutar múltiples versiones. La mitad más difícil es decidir en qué salida confiar.

¿Qué es la programación de N versiones?

La programación de N versiones es una técnica de tolerancia a fallos en la que ejecutas múltiples implementaciones independientes de la misma especificación y combinas sus resultados. La forma clásica es la redundancia modular triple: tres sistemas votan, y gana la mayoría. Se remonta al hardware crítico para la seguridad, piensa en aviónica, donde un solo bug podría matar personas.

La misma idea ha resurgido en la ingeniería de IA. Cuando pides a un LLM que genere código, podrías muestrear cinco completions diferentes. Cuando tienes un parser legacy y una reescritura, podrías ejecutar ambos en paralelo y compararlos. Las raíces en hardware se notan. Tomamos prestada la arquitectura sin siempre tomar prestada la lógica de selección que la hace funcionar.

En hardware, las salidas son bits. En software, las salidas son datos estructurados, cadenas, rankings o effects secundarios. El voto por mayoría asume que la igualdad es barata de calcular y común de encontrar. Para la mayoría de los problemas de software, ninguna de las dos suposiciones se cumple.

La trampa del voto: por qué “la más común” no es “la más correcta”

Me encontré con esto el año pasado con una refactorización de ranking de búsqueda. Teníamos cinco algoritmos de ranking: el sistema legacy, dos enfoques basados en modelos y dos líneas base heurísticas. En una query de muestra, cuatro de ellos devolvieron listas top-10 diferentes. Ninguna coincidía exactamente. No había mayoría a la que votar.

Este es el caso normal, no el caso límite. Diferentes implementaciones optimizan para cosas diferentes. Una podría favorecer la recencia. Otra podría ponderar la popularidad. Una tercera podría tener un bug que solo aparece los martes. Si votas por coincidencia exacta de cadenas, te atarás a la implementación más mediocre, la que devuelve la salida más insípida y menos sorprendente.

El voto por coincidencia exacta también falla en silencio. Dos implementaciones pueden devolver la misma respuesta incorrecta porque comparten una suposición errónea o un bug copiado. Los fallos correlacionados rompen completamente la redundancia de N versiones. Si tres de tus cinco parsers fueron entrenados con el mismo dataset sesgado, su acuerdo no significa nada.

Cómo funciona realmente el differential testing

El mejor enfoque es el differential testing con comparación estructurada. En lugar de preguntarte “qué salidas son idénticas”, te preguntas “qué salida es la mejor según criterios que puedo definir y medir”.

Empieza definiendo una equivalence function para tu dominio. Para rankings de búsqueda, podrías comparar usando normalized discounted cumulative gain (NDCG). Para parsers JSON, podrías comparar los grafos de objetos resultantes. Para salidas de cadenas, podrías usar semantic similarity o una puntuación de downstream task. La clave es que la igualdad se convierte en un espectro, no en un interruptor binario.

A continuación, define una scoring function que mapee cada salida a un escalar. Aquí es donde entra el conocimiento del dominio. Una scoring function para una tarea de code generation podría combinar compilation success, test pass rate, runtime performance y output length. Los pesos exactos importan menos que el hecho de que sean explícitos.

Con las puntuaciones en mano, la selección se convierte en una optimización simple. Elige la salida con la puntuación más alta. Si hay empate, usa una heurística de fallback, como preferir la implementación con la lowest historical error rate.

Aquí tienes un selector que puedes adaptar. Ejecuta N variantes, puntúa cada salida y devuelve la mejor junto con metadatos de diagnóstico:

from dataclasses import dataclass
from typing import Callable, List, Optional, TypeVar
import statistics

T = TypeVar("T")

@dataclass
class VariantResult:
    variant_id: str
    output: Optional[T]
    error: Optional[Exception]
    score: float = 0.0

class NVersionSelector:
    def __init__(
        self,
        score_fn: Callable[[T], float],
        equivalence_fn: Optional[Callable[[T, T], float]] = None,
        tie_breaker: Optional[Callable[[List[VariantResult]], VariantResult]] = None,
    ):
        self.score_fn = score_fn
        self.equivalence_fn = equivalence_fn or (lambda a, b: 1.0 if a == b else 0.0)
        self.tie_breaker = tie_breaker

    def select(self, variants: List[Callable[..., T]], *args, **kwargs) -> VariantResult:
        results: List[VariantResult] = []

        for variant in variants:
            try:
                output = variant(*args, **kwargs)
                results.append(VariantResult(
                    variant_id=variant.__name__,
                    output=output,
                    error=None,
                ))
            except Exception as exc:
                results.append(VariantResult(
                    variant_id=variant.__name__,
                    output=None,
                    error=exc,
                ))

        # Score only successful runs
        for r in results:
            if r.output is not None:
                r.score = self.score_fn(r.output)

        # Filter to successful, scored results
        valid = [r for r in results if r.error is None and r.output is not None]
        if not valid:
            raise RuntimeError("All variants failed")

        best_score = max(r.score for r in valid)
        candidates = [r for r in valid if r.score == best_score]

        if len(candidates) == 1:
            return candidates[0]

        if self.tie_breaker:
            return self.tie_breaker(candidates)

        return self._default_tie_break(candidates, valid)

    def _default_tie_break(
        self, candidates: List[VariantResult], all_valid: List[VariantResult]
    ) -> VariantResult:
        # Prefer the candidate whose output is most similar to other outputs
        def consensus_score(candidate: VariantResult) -> float:
            similarities = [
                self.equivalence_fn(candidate.output, other.output)
                for other in all_valid
                if other.variant_id != candidate.variant_id
            ]
            return statistics.mean(similarities) if similarities else 0.0

        return max(candidates, key=consensus_score)

La score_fn es donde codificas qué significa “mejor” para tu problema. La equivalence_fn maneja el caso de empate en las puntuaciones midiendo cuán similar es una salida al resto del grupo. Esto generaliza el voto por mayoría en algo que funciona cuando dos salidas no coinciden exactamente.

Puntuar implementaciones con evaluación multicriterio

Una puntuación escalar única es limpia, pero peligrosa si colapsas demasiadas dimensiones en un solo número. Prefiero un enfoque de dos niveles.

El nivel uno es un hard filter. Las salidas que fallan en la compilation, violan invariantes o se bloquean se descartan inmediatamente. No hay crédito parcial.

El nivel dos es una soft score para los supervivientes. Esta puede ser una weighted sum, pero mantén los pesos explícitos y ajustables. Si descubres que tu selector prefiere consistentemente respuestas rápidas pero incorrectas, puedes aumentar el correctness weight sin reescribir la arquitectura.

def score_generated_code(output: str) -> float:
    if not compiles(output):
        return -1.0  # Hard filter

    tests_passed = run_test_suite(output)
    execution_time = benchmark(output)
    line_count = len(output.splitlines())

    # Weighted sum on survivors only
    return (
        0.6 * tests_passed
        + 0.3 * (1.0 / (1.0 + execution_time))
        + 0.1 * (1.0 / (1.0 + line_count))
    )

Los pesos de arriba son arbitrarios. Afínalos contra un held-out validation set y actualízalos cuando tus requisitos cambien. No los entierres dentro de una clase donde se vuelvan invisibles.

Cuando esto falla: fallos correlacionados y coste en tiempo de ejecución

La selección de N versiones no es gratuita. Ejecutar cinco variantes significa cinco veces el compute, cinco veces la latencia y cinco veces la carga de mantenimiento. Si tus implementaciones son llamadas a LLM, ese coste se mide en dólares y segundos. Si son microservicios, se mide en queue depth y thread count.

El riesgo mayor es el fallo correlacionado. Cinco implementaciones no te dan cinco muestras independientes de una distribución de bugs. Comparten lenguajes, librerías, datos de entrenamiento y autores humanos. Si las cinco usan la misma regular expression para parsear fechas, todas fallarán con la misma entrada malformada. La diversidad es difícil de imponer. Requiere auditar activamente las dependencias compartidas y la lógica copiada.

También está la cuestión de qué hacer cuando el selector mismo está equivocado. Si tu scoring function tiene un punto ciego, seleccionarás consistentemente salidas malas y nunca te darás cuenta. Monitoriza la selection distribution. Si una variante nunca se elige, o es inútil o tu score function está sesgada. De cualquier manera, deberías investigar.

Juntándolo todo: un selector que puedes desplegar hoy

No necesitas un sistema distribuido para empezar. Envuelve tu función existente en un selector, añade una implementación alternativa y define un único criterio de puntuación que te importe. Ejecútalos en paralelo. Compara las salidas. Registra las puntuaciones.

Después de una semana de registry, revisa los casos donde las variantes discreparon. Esa discrepancia es un regalo. Te dice dónde tu especificación es ambigua, dónde tu score function está equivocada o dónde una implementación tiene un bug que las demás no.

Escala gradualmente. Dos variantes con un buen selector superan a cinco variantes con voto por mayoría.

Preguntas frecuentes: fallos correlacionados, sistemas con estado y sobrecarga en tiempo de ejecución

¿Qué pasa si las cinco implementaciones devuelven resultados diferentes?

Este es el caso esperado para problemas no triviales. Usa una scoring function para clasificar las salidas en lugar de buscar coincidencias exactas. Si no puedes definir una buena score function, el problema no es el selector. Es que aún no sabes qué significa “correcto” en tu dominio.

¿Cómo evito fallos correlacionados entre variantes?

Audita las dependencias compartidas, el código copiado y los datos de entrenamiento comunes. Obliga al menos a una variante a usar un lenguaje o framework diferente. El objetivo son distribuciones de error no correlacionadas, lo cual es más difícil de lo que parece.

¿Funciona la programación de N versiones para sistemas con estado?

Funciona mal para sistemas con side effects. Si tu función escribe en una base de datos o carga una tarjeta de crédito, ejecutarla cinco veces es destructivo. La selección de N versiones funciona mejor para pure functions, read-only queries u operaciones donde puedas escenificar el side effect después de la selección.

¿Cuánta sobrecarga añade ejecutar cinco variantes?

La latencia está limitada por la variante más lenta a menos que las ejecutes en paralelo. La CPU y la memoria escalan linealmente. Empieza con dos variantes y mide antes de comprometerte con cinco. El coste operativo suele importar más que la lógica de selección.