Vous avez cinq implémentations de la même fonction. Trois retournent le même résultat. Une est légèrement différente. Une lève une exception. Laquelle est correcte ?
La plupart des équipes se rabattent sur le vote majoritaire. Cela fonctionne bien quand les sorties sont identiques et les erreurs évidentes. Cela s’effondre dès que vos implémentations divergent de manière subtile, ou quand chaque variante retourne une réponse différente. La programmation N-version ne résout que la première moitié du problème : exécuter plusieurs versions. La moitié la plus difficile consiste à décider à laquelle on peut faire confiance.
Qu’est-ce que la programmation N-version ?
La programmation N-version est une technique de tolérance aux fautes où vous exécutez plusieurs implémentations indépendantes de la même spécification et combinez leurs résultats. La forme classique est la redondance modulaire triple : trois systèmes votent, et la majorité l’emporte. Cela remonte au matériel critique pour la sécurité, pensez à l’avionique, où un seul bug pouvait coûter des vies humaines.
La même idée a refait surface dans l’ingénierie IA. Quand vous demandez à un LLM de générer du code, vous pouvez échantillonner cinq completions différentes. Quand vous avez un parser legacy et une réécriture, vous pouvez les exécuter en parallèle et comparer. Les racines matérielles se voient. Nous avons emprunté l’architecture sans toujours emprunter la logique de sélection qui la fait fonctionner.
Dans le matériel, les sorties sont des bits. Dans le logiciel, les sorties sont des données structurées, des chaînes de caractères, des classements ou des effets de bord. Le vote majoritaire suppose que l’égalité est peu coûteuse à calculer et fréquente à trouver. Pour la plupart des problèmes logiciels, aucune de ces deux hypothèses ne tient.
Le piège du vote : pourquoi « la plus commune » n’est pas « la plus correcte »
Je suis tombé là-dessus l’année dernière lors d’une refactorisation de classement de recherche. Nous avions cinq algorithmes de classement : le système legacy, deux approches basées sur des modèles, et deux baselines heuristiques. Sur une requête d’exemple, quatre d’entre eux retournaient des top-10 différents. Aucun ne correspondait exactement. Il n’y avait pas de majorité pour laquelle voter.
C’est le cas normal, pas le cas limite. Différentes implémentations optimisent pour des choses différentes. L’une peut favoriser la récence. Une autre peut pondérer la popularité. Une troisième peut avoir un bug qui n’apparaît que le mardi. Si vous votez par correspondance exacte de chaîne, vous vous lierez à l’implémentation la plus médiocre, celle qui retourne la sortie la plus terne, la moins surprenante.
Le vote par correspondance exacte échoue aussi silencieusement. Deux implémentations peuvent retourner la même mauvaise réponse parce qu’elles partagent une mauvaise hypothèse ou un bug copié. Les défaillances corrélées brisent complètement la redondance N-version. Si trois de vos cinq parsers ont été entraînés sur le même jeu de données biaisé, leur accord ne signifie rien.
Comment le differential testing fonctionne réellement
La meilleure approche est le differential testing avec comparaison structurée. Au lieu de demander « quelles sorties sont identiques », vous demandez « quelle sortie est la meilleure selon des critères que je peux définir et mesurer ».
Commencez par définir une fonction d’équivalence pour votre domaine. Pour les classements de recherche, vous pouvez comparer en utilisant le normalized discounted cumulative gain (NDCG). Pour les parsers JSON, vous pouvez comparer les graphes d’objets résultants. Pour les sorties textuelles, vous pouvez utiliser la similarité sémantique ou un score de tâche en aval. L’essentiel est que l’égalité devient un spectre, pas un interrupteur binaire.
Ensuite, définissez une fonction de scoring qui mappe chaque sortie vers un scalaire. C’est ici qu’intervient la connaissance du domaine. Une fonction de scoring pour une tâche de génération de code pourrait combiner le succès de compilation, le taux de passage des tests, les performances à l’exécution et la longueur de la sortie. Les poids exacts importent moins que le fait qu’ils soient explicites.
Une fois les scores en main, la sélection devient une simple optimisation. Choisissez la sortie avec le score le plus élevé. En cas d’égalité, utilisez une heuristique de secours, comme privilégier l’implémentation avec le taux d’erreur historique le plus bas.
Voici un sélecteur que vous pouvez adapter. Il exécute N variantes, attribue un score à chaque sortie, et retourne la meilleure avec ses métadonnées de diagnostic :
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)
Le score_fn est là où vous encodez ce que signifie « meilleur » pour votre problème. Le equivalence_fn gère le cas où les scores sont à égalité en mesurant à quel point une sortie est similaire au reste du groupe. Cela généralise le vote majoritaire en quelque chose qui fonctionne quand aucune sortie ne correspond exactement à une autre.
Évaluer les implémentations avec une évaluation multi-critères
Un score scalaire unique est propre, mais dangereux si vous réduisez trop de dimensions à un seul nombre. Je préfère une approche en deux niveaux.
Le premier niveau est un filtre strict. Les sorties qui échouent à la compilation, violent des invariants ou plantent sont immédiatement écartées. Pas de point partiel.
Le deuxième niveau est un score souple pour les survivants. Cela peut être une somme pondérée, mais gardez les poids explicites et ajustables. Si vous constatez que votre sélecteur privilégie systématiquement des réponses rapides mais erronées, vous pouvez augmenter le poids de la correction sans réécrire l’architecture.
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))
)
Les poids ci-dessus sont arbitraires. Ajustez-les sur un jeu de validation held-out, et mettez-les à jour quand vos besoins changent. Ne les enfouissez pas à l’intérieur d’une classe où ils deviendraient invisibles.
Quand cela s’effondre : défaillances corrélées et coût d’exécution
La sélection N-version n’est pas gratuite. Exécuter cinq variantes signifie cinq fois le calcul, cinq fois la latence et cinq fois la charge de maintenance. Si vos implémentations sont des appels à des LLM, ce coût se mesure en dollars et en secondes. Si ce sont des microservices, il se mesure en profondeur de file et en nombre de threads.
Le risque plus grand est la défaillance corrélée. Cinq implémentations ne vous donnent pas cinq tirages indépendants d’une distribution de bugs. Elles partagent des langages, des bibliothèques, des données d’entraînement et des auteurs humains. Si les cinq utilisent la même expression régulière pour parser les dates, elles planteront toutes sur la même entrée malformée. La diversité est difficile à imposer. Elle nécessite d’auditer activement les dépendances partagées et la logique copiée.
Il y a aussi la question de quoi faire quand le sélecteur lui-même est erroné. Si votre fonction de scoring a un angle mort, vous sélectionnerez systématiquement de mauvaises sorties sans jamais vous en rendre compte. Surveillez la distribution des sélections. Si une variante n’est jamais choisie, elle est soit inutile, soit votre fonction de score est biaisée. Dans les deux cas, vous devriez investiguer.
Mettre les choses ensemble : un sélecteur que vous pouvez déployer dès aujourd’hui
Vous n’avez pas besoin d’un système distribué pour commencer. Enveloppez votre fonction existante dans un sélecteur, ajoutez une implémentation alternative, et définissez un seul critère de scoring qui compte pour vous. Exécutez-les en parallèle. Comparez les sorties. Enregistrez les scores.
Après une semaine d’enregistrement, examinez les cas où les variantes divergeaient. Cette divergence est un cadeau. Elle vous indique où votre spécification est ambiguë, où votre fonction de score est erronée, ou où une implémentation a un bug que les autres n’ont pas.
Montez en charge progressivement. Deux variantes avec un bon sélecteur valent mieux que cinq variantes avec un vote majoritaire.
FAQ : défaillances corrélées, systèmes stateful et surcharge d’exécution
Que se passe-t-il si les cinq implémentations retournent des résultats différents ?
C’est le cas attendu pour les problèmes non triviaux. Utilisez une fonction de scoring pour classer les sorties au lieu de chercher des correspondances exactes. Si vous ne pouvez pas définir une bonne fonction de score, le problème n’est pas le sélecteur. C’est que vous ne savez pas encore ce que signifie « correct » pour votre domaine.
Comment empêcher les défaillances corrélées entre variantes ?
Auditez les dépendances partagées, le code copié et les données d’entraînement communes. Forcez au moins une variante à utiliser un langage ou un framework différent. L’objectif est des distributions d’erreurs non corrélées, ce qui est plus difficile qu’il n’y paraît.
La programmation N-version fonctionne-t-elle pour les systèmes stateful ?
Elle fonctionne mal pour les systèmes avec effets de bord. Si votre fonction écrit dans une base de données ou débite une carte de crédit, l’exécuter cinq fois est destructrice. La sélection N-version fonctionne mieux pour les fonctions pures, les requêtes en lecture seule, ou les opérations où vous pouvez différer l’effet de bord après la sélection.
Quelle surcharge ajoute l’exécution de cinq variantes ?
La latence est bornée par la variante la plus lente, sauf si vous les exécutez en parallèle. Le CPU et la mémoire évoluent linéairement. Commencez avec deux variantes et mesurez avant de vous engager sur cinq. Le coût opérationnel compte souvent plus que la logique de sélection.