Você tem cinco implementações da mesma função. Três retornam o mesmo resultado. Uma é ligeiramente diferente. Uma lança uma exceção. Qual está certa?

A maioria das equipes recorre ao voto majoritário. Isso funciona bem quando as saídas são idênticas e os erros são óbvios. Desmorona no momento em que suas implementações discordam de formas sutis, ou quando cada variante retorna uma resposta diferente. N-version programming só resolve a primeira metade do problema: executar várias versões. A metade mais difícil é decidir qual saída confiar.

O que é N-version programming?

N-version programming é uma técnica de tolerância a falhas em que você executa várias implementações independentes da mesma especificação e combina seus resultados. A forma clássica é a redundância modular tripla: três sistemas votam, e a maioria vence. Remonta a hardware de segurança crítica, pense em aviônica, onde um único bug poderia matar pessoas.

A mesma ideia ressurgiu em AI engineering. Quando você pede a um LLM para gerar código, pode amostrar cinco completions diferentes. Quando você tem um parser legado e uma reescrita, pode executar ambos em paralelo e comparar. As raízes em hardware são evidentes. Emprestamos a arquitetura sem sempre emprestar a lógica de seleção que a faz funcionar.

Em hardware, as saídas são bits. Em software, as saídas são dados estruturados, strings, rankings ou side effects. O voto majoritário assume que a igualdade é barata de computar e comum de encontrar. Para a maioria dos problemas de software, nenhuma das duas suposições se sustenta.

A armadilha do voto: por que “mais comum” não é “mais correto”

Deparei-me com isso no ano passado durante um refactor de search ranking. Tínhamos cinco algoritmos de ranking: o sistema legado, duas abordagens baseadas em modelos e duas baselines heurísticas. Em uma query de amostra, quatro deles retornaram listas top-10 diferentes. Nenhuma correspondia exatamente. Não havia maioria para votar.

Este é o caso normal, não o edge case. Implementações diferentes otimizam para coisas diferentes. Uma pode favorecer recência. Outra pode ponderar popularidade. Uma terceira pode ter um bug que só aparece às terças-feiras. Se você votar por exact string match, ficará preso à implementação mais medíocre, aquela que retorna a saída mais insípida e menos surpreendente.

O voto por exact match também falha silenciosamente. Duas implementações podem retornar a mesma resposta errada porque compartilham uma suposição ruim ou um bug copiado. Falhas correlacionadas quebram completamente a redundância de N-version. Se três dos seus cinco parsers foram treinados no mesmo dataset enviesado, o acordo entre eles não significa nada.

Como differential testing funciona na prática

A abordagem melhor é differential testing com comparação estruturada. Em vez de perguntar “quais saídas são idênticas”, você pergunta “qual saída é a melhor segundo critérios que posso definir e medir”.

Comece definindo uma equivalence function para o seu domínio. Para search rankings, você pode comparar usando normalized discounted cumulative gain (NDCG). Para JSON parsers, pode comparar os object graphs resultantes. Para saídas de string, pode usar semantic similarity ou um downstream task score. A chave é que a igualdade se torna um espectro, não um switch binário.

Em seguida, defina uma scoring function que mapeia cada saída para um escalar. É aqui que o conhecimento de domínio entra. Uma scoring function para uma tarefa de code generation pode combinar compilation success, test pass rate, runtime performance e output length. Os pesos exatos importam menos do que o fato de que são explícitos.

Com scores em mãos, a seleção se torna uma otimização simples. Escolha a saída com o maior score. Haver empate, use uma heurística de fallback, como preferir a implementação com a menor taxa de erro histórica.

Aqui está um selector que você pode adaptar. Ele executa N variantes, pontua cada saída e retorna a melhor junto com metadados 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)

A score_fn é onde você codifica o que “melhor” significa para o seu problema. A equivalence_fn lida com o caso em que scores empatam, medindo quão similar uma saída é ao restante do grupo. Isso generaliza o voto majoritário em algo que funciona quando nenhuma das duas saídas correspondem exatamente.

Pontuando implementações com avaliação multi-critério

Um único scalar score é limpo, mas perigoso se você colapsar dimensões demais em um único número. Prefiro uma abordagem de dois níveis.

O primeiro nível é um hard filter. Saídas que falham na compilation, violam invariants ou crasham são descartadas imediatamente. Nenhum crédito parcial.

O segundo nível é um soft score para os sobreviventes. Pode ser uma weighted sum, mas mantenha os pesos explícitos e ajustáveis. Se você perceber que o selector prefere consistentemente respostas rápidas mas erradas, pode aumentar o peso da correctness sem reescrever a arquitetura.

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))
    )

Os pesos acima são arbitrários. Ajuste-os contra um held-out validation set e atualize-os quando seus requisitos mudarem. Não os enterre dentro de uma classe onde se tornem invisíveis.

Quando isso desmorona: falhas correlacionadas e custo de runtime

A seleção de N-version não é de graça. Executar cinco variantes significa cinco vezes o compute, cinco vezes a latency e cinco vezes a carga de manutenção. Se suas implementações são chamadas a LLMs, esse custo é medido em dólares e segundos. Se são microservices, é medido em queue depth e thread count.

O risco maior é a falha correlacionada. Cinco implementações não te dão cinco draws independentes de uma distribuição de bugs. Elas compartilham linguagens, bibliotecas, training data e autores humanos. Se todas as cinco usam a mesma regular expression para parsear datas, todas vão quebrar no mesmo input malformado. A diversidade é difícil de impor. Requer auditar ativamente por shared dependencies e copied logic.

Há também a questão do que fazer quando o próprio selector está errado. Se sua scoring function tem um blind spot, você vai selecionar consistentemente saídas ruins e nunca perceber. Monitore a selection distribution. Se uma variante nunca é escolhida, ou é inútil ou sua score function está enviesada. De qualquer forma, você deve investigar.

Juntando tudo: um selector que você pode colocar em produção hoje

Você não precisa de um distributed system para começar. Envolva sua função existente em um selector, adicione uma implementação alternativa e defina um único critério de scoring que importe para você. Execute-os em paralelo. Compare as saídas. Registre os scores.

Depois de uma semana de logging, revise os casos em que as variantes discordaram. Essa discordância é um presente. Ela mostra onde sua especificação é ambígua, onde sua score function está errada ou onde uma implementação tem um bug que as outras não têm.

Escale gradualmente. Duas variantes com um bom selector vencem cinco variantes com voto majoritário.

FAQ: falhas correlacionadas, sistemas stateful e overhead de runtime

E se todas as cinco implementações retornarem resultados diferentes?

Este é o caso esperado para problemas não triviais. Use uma scoring function para classificar as saídas em vez de procurar por exact matches. Se você não consegue definir uma boa score function, o problema não é o selector. É que você ainda não sabe o que “correto” significa para o seu domínio.

Como evito falhas correlacionadas entre variantes?

Audite por shared dependencies, copied code e training data comum. Force pelo menos uma variante a usar uma linguagem ou framework diferente. O objetivo são uncorrelated error distributions, o que é mais difícil do que parece.

N-version programming funciona para sistemas stateful?

Funciona mal para sistemas com side effects. Se sua função escreve em um banco de dados ou cobra um cartão de crédito, executá-la cinco vezes é destrutivo. A seleção de N-version funciona melhor para pure functions, read-only queries ou operações em que você pode preparar o side effect após a seleção.

Quanto overhead adiciona executar cinco variantes?

A latency é limitada pela variante mais lenta, a menos que você as execute em paralelo. CPU e memória escalam linearmente. Comece com duas variantes e meça antes de se comprometer com cinco. O custo operacional geralmente importa mais do que a lógica de seleção.