L’hypothèse qui s’est trop approchée du soleil

Au début des années 1980, la NASA était confrontée à une question qui hante encore l’ingénierie safety-critical aujourd’hui : comment tolérer des bugs que vous n’avez pas encore trouvés ? Leur réponse était la programmation N-version. Donner la même spécification à trois équipes indépendantes. Exécuter les trois programmes en parallèle. Voter sur les sorties. Si une équipe a écrit un bug, les deux autres le surclasseraient par le vote.

Cela ressemble à du bon sens mathématique. C’est aussi faux.

En 1986, John Knight et Nancy Leveson ont publié le résultat d’une expérience à grande échelle financée par la NASA. Vingt-sept étudiants diplômés de deux universités ont écrit des implémentations indépendantes de la même spécification d’intercepteur de missiles balistiques. Chaque programme était individuellement extrêmement fiable. Six versions n’ont jamais échoué du tout. Vingt-trois ont réussi plus de 99,9 % d’un million de cas de test générés aléatoirement.

Mais lorsque plusieurs versions échouaient sur la même entrée, elles échouaient ensemble bien plus souvent que l’indépendance statistique ne le prédisait. Le z-score était de 100,55. À un intervalle de confiance de 99 %, l’hypothèse nulle d’indépendance était anéantie. La NASA avait parié que l’erreur humaine est aléatoire. Knight et Leveson ont prouvé qu’elle s’agglomère.

Ce que la programmation N-version promet réellement

La logique derrière la programmation N-version est empruntée à la redondance matérielle. Si vous fixez trois gyroscopes identiques à une fusée et que l’un dérive, les deux autres surclassent la dérive. Les défaillances sont indépendantes parce que les gyroscopes sont des objets physiques soumis à des variations de fabrication indépendantes, des gradients de température et des modes de vibration.

Le logiciel est différent. Lorsque vous demandez à trois équipes de résoudre le même problème, vous n’obtenez pas trois lancers de dés indépendants. Vous obtenez trois humains qui ont lu la même spécification ambiguë, appris les mêmes algorithmes dans les mêmes manuels, et écrivent du code dans le même langage avec la même bibliothèque standard. Leurs erreurs sont corrélées parce que leurs entrées sont corrélées.

L’architecture N-version ressemble à ceci en pratique :

from typing import Callable, List, TypeVar

T = TypeVar('T')

def n_version_vote(
    implementations: List[Callable[[float], T]],
    input_value: float
) -> T:
    """Run N versions and return the majority output."""
    results = [impl(input_value) for impl in implementations]
    
    # Simple majority vote
    from collections import Counter
    counts = Counter(str(r) for r in results)
    most_common = counts.most_common(1)[0][0]
    
    # Return the actual value that matched the winning string
    for r in results:
        if str(r) == most_common:
            return r
    
    raise RuntimeError("No consensus")

Ce voter est la partie facile. La partie difficile est l’hypothèse qui se cache à l’intérieur : que implementations[0], implementations[1] et implementations[2] échouent sur des sous-ensembles non corrélés de l’espace d’entrée. Knight et Leveson ont montré que cette hypothèse est le défaut.

D’où viennent les défaillances corrélées

L’expérience Knight-Leveson a révélé deux mécanismes distincts de défaillance en mode commun, et aucun n’est réparable en demandant aux équipes de “faire plus d’efforts.”

Le premier est l’ambiguïté de spécification. La spécification de l’intercepteur de missiles contenait des cas limites qui étaient réellement difficiles. Huit des vingt-sept programmeurs ont mal géré le cas où trois points radar étaient colinéaires. Les erreurs étaient différentes. L’un avait une erreur off-by-one dans un indice de tableau. L’un utilisait un algorithme avec une mauvaise stabilité numérique. L’un avait oublié un cas limite entièrement. Mais les défaillances s’aggloméraient sur les mêmes entrées difficiles parce que le problème lui-même était difficile, et non pas parce que les programmeurs étaient négligents.

C’est le “facteur de difficulté.” Lorsqu’une entrée se trouve à une condition limite, requiert des calculs en virgule flottante délicats, ou implique une transition d’état sous-spécifiée, des équipes indépendantes ont tendance à avoir des difficultés au même endroit. Leurs solutions diffèrent. Leurs régions de défaillance se chevauchent.

Le deuxième mécanisme est les modèles mentaux partagés. Les programmeurs formés au même curriculum appliquent les mêmes heuristiques. Ils choisissent le même algorithme de tri, le même pattern de copie défensive, la même comparaison epsilon pour l’égalité en virgule flottante. Lorsque cette valeur par défaut partagée est incorrecte pour le problème en question, chaque équipe tombe du même précipice.

Le Software Safety Guidebook de la NASA a finalement reconnu cela explicitement. Il note que “de nombreux professionnels considèrent la programmation N-version comme inefficace, voire contre-productive.” Dans une étude de la NASA sur un avion expérimental, chaque problème logiciel trouvé pendant les tests provenait du système de gestion de redondance. Le logiciel de contrôle de vol principal était impeccable. La couche N-version était la seule chose qui tombait en panne.

La taxe de complexité dont personne ne parle

Même si la programmation N-version fonctionnait comme annoncé, elle exigerait un lourd tribut. Vous payez pour trois implémentations complètes. Vous payez pour un voter qui contient lui-même de la logique. Vous payez pour la surcharge opérationnelle d’exécuter trois processus et de réconcilier leurs sorties.

Ce voter n’est pas un morceau de code trivial. Il doit gérer les égalités, les timeouts, les formats de sortie divergents, et le cas où la majorité a tort. Brunelle et Eckhardt ont démontré cela en 1985 avec le système d’exploitation SIFT. Deux nouvelles versions N ont surclassé l’implémentation originale, correcte, et ont produit une mauvaise réponse. Le système de redondance a créé l’erreur qu’il était censé prévenir.

La complexité se cumule de manières difficiles à mesurer jusqu’à ce qu’elle vous morde. Vous avez maintenant trois déployables à versionner, trois matrices de test à maintenir, et trois équipes qui interpréteront différemment les changements de spécification lorsque les exigences évolueront inévitablement. La surface d’exposition à l’erreur humaine croît plus vite que la fiabilité ne s’améliore.

Ce qui fonctionne réellement à la place

La NASA n’a pas abandonné la tolérance aux fautes. Elle a abandonné l’hypothèse spécifique que la diversité vient de l’indépendance. Les systèmes modernes à haute assurance utilisent une combinaison de techniques qui s’attaquent aux modes de défaillance réels que Knight et Leveson ont identifiés.

Les méthodes formelles pour le chemin critique. Plutôt qu’espérer que trois équipes interprètent correctement une spécification, écrivez la spécification sous une forme vérifiable par machine. Des outils comme TLA+, Coq et SPIN vérifient qu’une conception satisfait ses invariants avant que quiconque n’écrive une ligne de code d’implémentation. Le Remote Agent Experiment de la NASA a utilisé SPIN pour trouver des bugs de concurrence qui avaient échappé à des tests extensifs.

La diversité par des stacks technologiques dissemblables. Si vous avez besoin de redondance, faites-la en profondeur. Le système de contrôle de vol de l’Airbus A330 utilise des architectures matérielles dissemblables, différents langages de programmation, et des compilateurs indépendants pour ses canaux primaire et de secours. L’objectif n’est pas seulement des équipes indépendantes mais des modes de défaillance indépendants à chaque couche de la stack.

La simplification plutôt que la duplication. Le Software Safety Guidebook de la NASA recommande finalement la programmation N-version uniquement pour les “petites fonctions simples.” La leçon n’est pas glamour mais efficace : le système le plus fiable est celui qui est assez simple pour être raisonné directement. Chaque ligne de code de gestion de redondance est une ligne qui peut échouer.

Voici à quoi ressemble une approche simplifiée et pilotée par la spécification en pratique. Au lieu de trois implémentations opaques votant sur les sorties, encodez l’invariant critique explicitement et vérifiez-le au runtime :

from dataclasses import dataclass
from typing import Optional

@dataclass(frozen=True)
class LaunchCommand:
    thrust_level: float  # 0.0 to 1.0
    abort_flag: bool
    
    def is_valid(self) -> bool:
        """Runtime invariant check derived from the formal spec."""
        if not (0.0 <= self.thrust_level <= 1.0):
            return False
        if self.abort_flag and self.thrust_level > 0.0:
            return False
        return True

def execute_command(cmd: LaunchCommand) -> Optional[str]:
    if not cmd.is_valid():
        raise ValueError("Invariant violation: command violates safety spec")
    
    # Execute only after the single, explicit check passes.
    return f"Executing thrust={cmd.thrust_level}, abort={cmd.abort_flag}"

Ce n’est pas de la programmation N-version. C’est une implémentation avec une limite de sécurité explicite et testable. La limite est là où va votre effort. Pas dans l’espoir que deux équipes sur trois ont eu raison.

L’écho moderne

L’article de 1986 de Knight et Leveson porte un avertissement qui devient plus pertinent chaque année. Les défaillances corrélées ne nécessitent pas des équipes corrélées. Elles nécessitent des entrées corrélées et une difficulté corrélée. Alors que les outils de codage assistés par IA se multiplient, nous menons une nouvelle expérience N-version à l’échelle planétaire. Des modèles entraînés sur des corpus qui se chevauchent, sollicités avec des patterns similaires, produisent du code avec des modes de défaillance partagés.

Des recherches récentes sur la génération de code par LLM suggèrent des taux de co-erreur entre 15 % et 30 % pour les composants générés par IA. Le beta factor, la fraction de défaillances attribuable à une cause commune, pourrait déjà dépasser les valeurs des programmeurs humains mesurées par Knight et Leveson. Nous répétons l’expérience avec plus de participants et la même hypothèse erronée.

La NASA a appris cette leçon de la manière la plus coûteuse. Vous n’avez pas besoin de trois implémentations. Vous avez besoin d’une implémentation avec un invariant que vous pouvez énoncer, vérifier et faire confiance. Tout le reste est de l’optimisme déguisé en ingénierie.

FAQ

Qu’est-ce que la programmation N-version ?

La programmation N-version est une technique de tolérance aux fautes logicielle où plusieurs équipes indépendantes implémentent la même spécification. Les programmes s’exécutent en parallèle et un voter sélectionne la sortie majoritaire, sur l’hypothèse que des équipes indépendantes feront des erreurs indépendantes.

La NASA a-t-elle réellement utilisé la programmation N-version ?

Oui. La NASA a financé les premières recherches sur les logiciels multi-versions et à un moment donné certains documents de politique imposaient effectivement la programmation N-version pour les systèmes tolérants aux fautes. La séquence de mise sous tension des moteurs de la navette spatiale l’a utilisée, bien que la NASA ait ensuite restreint son emploi aux petites fonctions simples après que des études empiriques aient révélé ses limites.

Quelle était l’expérience Knight-Leveson ?

En 1986, John Knight et Nancy Leveson ont fait écrire à vingt-sept programmeurs des implémentations indépendantes d’une spécification d’intercepteur de missiles. Sur plus d’un million de tests, les programmes ont exhibé significativement plus de défaillances coïncidentes que l’indépendance statistique ne le prédisait, rejetant l’hypothèse fondamentale sous-tendant la programmation N-version.

La programmation N-version vaut-elle parfois la peine d’être utilisée ?

Dans des cas limités. Les directives actuelles de la NASA suggèrent qu’elle peut être appropriée pour de petites fonctions bien définies où une véritable diversité peut être imposée. Pour le développement d’applications générales, le coût de complexité et le risque de défaillance corrélée l’emportent sur les bénéfices. Les méthodes formelles, la vérification d’invariants au runtime et la simplification sont généralement de meilleurs investissements.