Tests différentiels : le seul garde-fou contre les hallucinations des modèles en rétro-ingénierie

Dernière mise à jour: 2026-07-30 10:58:49

Le modèle vient d’analyser votre fonction terminale et livre un diagnostic impeccablement formulé : « C’est du ChaCha20, avec seulement un nombre de rounds remplacé par une variable dérivée de la clé. » L’explication paraît solide. Suffisamment, peut-être, pour vous donner envie d’écrire directement la réimplémentation.

Ne le faites pas. Tant que cette affirmation n’est pas devenue un assert exécutable, elle ne reste qu’une phrase. Peu importe le nombre d’implémentations publiques que le modèle a pu voir : il ne peut pas vérifier que celle-ci est correcte. C’est une question de fait, non de formulation. La seule manière d’y répondre consiste à placer les sorties des deux versions côte à côte et à les comparer octet par octet.

C’est précisément le rôle du test différentiel. Il constitue l’étape 3 du processus en quatre étapes et l’aboutissement de chaque hypothèse issue de la recherche de divergences. Le modèle émet des soupçons ; le test différentiel rend le verdict.

Ne demandez pas au modèle de valider votre code

La question que les débutants posent volontiers au modèle est : « Pouvez-vous confirmer que cette implémentation est correcte ? »

C’est une impasse, pour trois raisons.

D’abord, un modèle est naturellement porté à vous suivre. La formulation « cette implémentation est-elle correcte ? » contient déjà son propre biais : vous espérez manifestement une réponse positive, et le modèle capte souvent cette attente avant de vous répondre « oui ».

Ensuite, il n’a aucun élément concret pour trancher. La seule définition utile de « correct » est la conformité des sorties de la boîte noire sur le même jeu d’entrées — et cette référence n’est pas dans son contexte. Sans vérité terrain, il ne peut qu’estimer si le code « semble plausible ». Or faire paraître un code plausible est justement l’un des points forts du code obfusqué.

Enfin, ce n’est tout simplement pas à lui de répondre. La correction n’est pas une opinion négociable : c’est le constat qu’une équation est vérifiée. Ne posez donc pas la question au modèle ; donnez-la à assert. Si le modèle dit que c’est juste mais que le test échoue, le test a raison. Si le modèle dit que c’est faux mais que tous les tests passent, le test a encore raison. Dès que vous déléguez le verdict au modèle, vous construisez sur une hallucination.

Choisir les bonnes entrées limites

La structure d’un test différentiel est simple : vous traitez l’original comme une boîte noire, puis vous comparez sa sortie à celle de votre réécriture, cas par cas, sur le même lot d’entrées. La valeur du test ne réside pas dans la boucle, mais dans le choix de ce lot.

Lancer dix mille chaînes aléatoires ordinaires et obtenir du vert ne prouve pratiquement rien. Les entrées usuelles empruntent le chemin principal ; les points de divergence se cachent généralement dans les cas limites. Ce sont donc les frontières qui apportent l’information :

# Boundary cases: derived from block size B
def boundary_cases(B: int) -> list[bytes]:
    return [
        b"",                 # empty: exposes initial state and padding logic
        b"\x00",             # single zero byte
        b"\xff",             # single high byte: checks sign-bit / unsigned handling
        b"A" * (B - 1),      # one below the block boundary
        b"A" * B,            # exactly one block
        b"A" * (B + 1),      # one above: checks carry and padding
        bytes(range(256)),   # full byte coverage: checks the alphabet map covers the whole domain
    ]

Chaque entrée sonde une branche précise. L’octet élevé \xff force le traitement du bit de signe à se révéler : la différence entre >>> et >> en JS, ou la présence d’un & 0xff en Python, apparaît immédiatement sur ce seul cas. Le triplet B-1 / B / B+1 est le plus révélateur : la logique de remplissage des blocs ne se manifeste qu’ici. La couverture complète des octets sert à contrôler les alphabets personnalisés : un caractère en trop ou en moins dans la table de correspondance, et ce cas échoue systématiquement.

Le squelette du test différentiel lui-même tient en moins de dix lignes :

# Original as black box, rewrite compared case by case
def diff_test(blackbox, rewritten, B: int) -> None:
    for case in boundary_cases(B):
        got, want = rewritten(case), blackbox(case)
        assert got == want, f"len={len(case)} hex={case.hex()}"

Un point important : lorsqu’une hypothèse de point de divergence concerne un algorithme standard, vous n’avez pas toujours besoin de la boîte noire originale. Les standards publics fournissent souvent des vecteurs de test faisant autorité. Par exemple, RFC 8439 §2.1.1 propose une paire entrée/sortie fixe pour le quarter round de ChaCha20 : avec a=0x11111111, b=0x01020304, c=0x9b8d6f43, d=0x01234567, vous obtenez a=0xea2a92f4, …. Faites d’abord passer le vecteur standard à votre réécriture, puis comparez-la à la boîte noire cible. Vous séparerez ainsi clairement deux problèmes : « mon implémentation de ChaCha est erronée » et « la cible a modifié ChaCha ».

Remarquez que le message de l’assert inclut len(case). C’est la ligne de diagnostic la plus utile de toute la suite de tests. Si, sur les sept cas, seul celui en B+1 échoue, le problème se situe presque certainement dans le padding ou la retenue ; si seule la couverture complète des octets échoue, cherchez du côté de la table d’alphabet. La longueur du cas défaillant désigne directement la couche fautive, sans tâtonnement.

Exécutez deux fois la même entrée

Lorsqu’on réécrit une logique de signature, le blocage le plus fréquent ne vient pas d’un algorithme mal identifié, mais d’une source d’entropie qui n’a pas encore été isolée.

Une seule ligne suffit à la détecter : exécuter deux fois la même entrée.

# Same input twice: differing results mean a hidden RNG / timestamp
def assert_deterministic(fn, case: bytes) -> None:
    assert fn(case) == fn(case), "unfixed entropy source or timestamp present"

Si les deux résultats diffèrent, l’implémentation mélange probablement un time.time(), un nonce, un compteur auto-incrémenté ou un autre élément qui varie à chaque appel. Dans cet état, toute comparaison différentielle est impossible : la boîte noire fournit une réponse différente à chaque exécution. À quoi pourriez-vous comparer votre résultat ?

La solution ne consiste pas à supprimer cette source d’entropie — la signature deviendrait alors incorrecte — mais à l’extraire du code pour l’injecter en paramètre. Pendant le test différentiel, vous la figez sur une valeur déterminée :

# Lift the entropy source into an injectable parameter, pin it with a stub for diffing
class Rewritten:
    def __init__(self, clock=time.time, rng=os.urandom):
        self._clock = clock          # formerly an inline time.time(), now injected
        self._rng = rng

    def __call__(self, data: bytes) -> bytes:
        ts = int(self._clock())      # for diffing, clock=lambda: 0
        nonce = self._rng(16)        # for diffing, rng=lambda n: b"\x00" * n
        ...

Appliquez le même principe à la boîte noire cible : repérez où l’horodatage ou l’aléa entre dans le calcul, puis trouvez comment le figer — en interceptant Date.now dans la page ou en passant une seed fixe à Node, par exemple. Une fois l’entropie verrouillée des deux côtés, la sortie redevient déterministe et la comparaison prend un sens. Lorsque la réécriture passe le test de bout en bout, remettez clock et rng sur leurs implémentations réelles.

C’est aussi une étape sur laquelle le modèle ne peut pas vous aider : l’emplacement de l’entropie et son mode d’injection relèvent du comportement à l’exécution. On les révèle en lançant deux fois le code, pas en le lisant.

Remontez les couches une à une, pas seulement la sortie finale

Supposons que la sortie globale de votre réécriture ne corresponde pas à celle de la boîte noire. Ne vous obstinez pas sur cette chaîne finale d’octets : elle résulte de plusieurs couches imbriquées, et vous ignorez encore laquelle est défectueuse.

Un signateur est généralement composé de couches : un hash ou un chiffrement par blocs au cœur, enveloppé dans une couche d’encodage — famille Base64, hexadécimal ou table personnalisée — puis dans une couche d’assemblage externe qui concatène un préfixe, insère des champs ou ajoute un en-tête de longueur. L’approche consiste à comparer depuis la couche la plus interne vers l’extérieur, sans avancer tant que la couche courante ne passe pas :

# Peel layer by layer: match the innermost first, then move outward
LAYERS = ["digest", "encode", "assemble"]   # inner → outer

def first_divergent_layer(bb_dump, rw_dump, case: bytes) -> str | None:
    for layer in LAYERS:
        if bb_dump(case)[layer] != rw_dump(case)[layer]:
            return layer                     # the first layer to diverge is the faulty one
    return None

Cela suppose que votre réécriture peut exporter l’état intermédiaire de chaque couche et que vous puissiez extraire les valeurs équivalentes depuis la boîte noire, généralement par instrumentation à l’exécution. L’effort supplémentaire est largement rentabilisé : la première couche qui diverge est celle qui contient l’erreur, et votre champ d’investigation se resserre immédiatement.

Cette méthode s’articule directement avec les quatre catégories de points de divergence : une divergence dans la couche digest signale généralement une constante perturbée ou un nombre de rounds modifié ; dans la couche encode, elle indique souvent un alphabet réordonné ; dans la couche assemble, elle révèle en général l’insertion d’un élément dans la sortie. La couche qui diverge vous indique quelle catégorie de l’article sur le fingerprinting examiner.

Pourquoi les fixtures prennent de la valeur avec le temps

Quand un test différentiel passe enfin au vert, la satisfaction est réelle. Mais ce succès reste ponctuel : demain, une nouvelle version peut arriver en amont et rendre entièrement fausse l’implémentation validée aujourd’hui.

L’artefact qui gagne réellement en valeur au fil du temps est la fixture : une table entrée connue → sortie connue, versionnée dans le dépôt.

# Fixture: known input → known output, committed to the repo
# Re-run on every upstream change; green yesterday, red today = upstream changed, not you
VECTORS = load_json("vectors.json")   # [{"in": "<hex>", "out": "<hex>"}, ...]

for v in VECTORS:
    got = rewritten(bytes.fromhex(v["in"])).hex()
    assert got == v["out"], v["in"]

Son utilité se révèle dès qu’une modification arrive en amont. Un jour, la CI passe au rouge : c’est le code que vous n’avez pas touché la veille qui échoue contre la fixture. Cette information est inestimable. Elle élimine l’hypothèse « j’ai fait une erreur » et désigne clairement le vrai problème : « l’amont a changé ». Sans fixture, vous passerez une demi-journée à déboguer du code parfaitement correct, faute de savoir si l’erreur vient de vous ou si la cible a déplacé les règles.

Les meilleures entrées à conserver dans une fixture sont précisément les cas limites vus plus haut : c’est déjà votre ensemble offrant la meilleure couverture.

À cette étape, le modèle n’a que deux rôles

La répartition des tâches dans un test différentiel est simple : le modèle fait deux choses, mais il ne participe jamais au verdict.

Première tâche : générer des cas. Produire en masse des entrées limites pour de nombreuses primitives, ou construire un lot d’entrées ne différant que d’un bit afin de tester une constante suspecte, relève de l’énumération, pas du raisonnement. Un modèle économique, avec une forte capacité de concurrence, est donc le choix le plus rationnel.

Deuxième tâche : expliquer un diff. Lorsqu’un cas échoue, présentez-lui les dumps des deux couches et demandez-lui d’identifier, à partir d’octets précis, où se produit la première divergence et à laquelle des quatre catégories elle appartient probablement. Cette étape réclame un raisonnement intermédiaire et la capacité d’« expliquer à partir des octets » : c’est ici que le modèle apporte réellement quelque chose.

Le verdict reste entre les mains d’assert, sans exception. L’explication du modèle est une piste, pas une conclusion ; des pistes erronées sont normales, et assert les arrête.

Ces deux tâches sollicitent des capacités distinctes. Utiliser un seul niveau de modèle pour l’ensemble revient soit à gaspiller de l’argent, soit à perdre en précision :

Sous-étape du test différentiel

Capacité requise

Choix

model id

Générer en masse des cas limites/de contrôle, sur de nombreuses primitives

Faible coût et forte concurrence ; l’énumération ne demande pas de raisonnement

Claude Sonnet 5

claude-sonnet-5

Lire un diff défaillant et l’expliquer à partir des octets

Raisonnement intermédiaire ; attribution sur une couche précise

GPT-5.6 Sol

gpt-5.6-sol

Analyser les causes profondes lorsque l’attribution ne converge pas, comme une perturbation de constante

Raisonnement solide ; inférence à travers des états intermédiaires sur plusieurs rounds

Claude Opus 5

claude-opus-5

Absorber de nombreux dumps de couches ou un lot complet de fixtures pour trouver la divergence

Contexte long

Kimi K3

kimi-k3

Le deuxième niveau est celui qui servira le plus. Pour savoir si la qualité d’attribution des différences justifie de choisir un modèle plutôt qu’un autre, un seul tour de test vous donnera la réponse. Le protocole est court :

  1. Choisissez un cas réellement défaillant dans votre suite différentielle, avec ses deux dumps de couches : celui de la boîte noire et celui de votre réécriture.

  2. Soumettez séparément le même diff à gpt-5.6-sol et à claude-opus-5, en ne demandant que deux choses : la couche de la première divergence et celle des quatre catégories de divergence à laquelle elle semble le plus probablement appartenir.

  3. Évaluez uniquement si l’attribution désigne un octet précis et une couche précise, ou si elle vous répond vaguement « cela pourrait être un problème de padding ».

  4. La précision de l’attribution est votre critère de sélection : elle détermine directement le nombre de cycles de correction nécessaires pour faire passer ce cas au vert.

Un seul tour vous montrera l’écart plus clairement que n’importe quel classement de benchmark.

Le vrai frein, c’est le coût du changement de modèle

Ces quatre niveaux viennent de trois fournisseurs : trois SDK, trois mécanismes d’authentification, trois formats d’erreur. Configurer votre client trois fois, simplement pour changer de niveau entre les sous-étapes, n’en vaut pas la peine. C’est précisément pourquoi la plupart des gens finissent par employer le même niveau pour tout, utilisent un modèle incapable d’aller au-delà de généralités pour attribuer les différences, puis enchaînent plusieurs cycles de correction sans comprendre pourquoi.

AIReiter uniformise cette couche : une clé, une interface compatible OpenAI, les quatre niveaux accessibles derrière, et un changement qui se limite au champ model du corps de la requête.

# Difference attribution: the mid-reasoning tier
curl https://aireiter.com/api/v1/chat/completions \
  -H "Authorization: Bearer $AIREITER_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "gpt-5.6-sol",
    "messages": [{"role": "user", "content": "<diff of the two layer dumps + locate the first divergent layer and deviation category>"}]
  }'

# Attribution won't converge, escalate to dig the root cause: change one field
#   "model": "claude-opus-5"
# Bulk-generate boundary cases:
#   "model": "claude-sonnet-5"

Vous utilisez déjà le SDK OpenAI ? Pointez base_url vers https://aireiter.com/api/v1 sans rien changer d’autre. Avec le SDK Anthropic, utilisez POST /api/v1/messages avec la même clé.

Côté tarifs, les modèles Claude bénéficient de 30 % de réduction sur le prix catalogue, et les modèles GPT sont à moitié prix. Dans ce workflow, la réduction s’applique exactement là où l’activité est la plus dense : l’attribution des différences est l’opération la plus fréquemment appelée dans les tests différentiels. Il y a un tour par cas défaillant, et chaque changement en amont implique de reconstruire les fixtures puis d’attribuer tout un nouveau lot de cas en échec. Le modèle de travail principal, gpt-5.6-sol, est un modèle GPT à moitié prix : la partie la plus intensive est donc directement divisée par deux. Les rares escalades vers claude-opus-5 pour investiguer une cause profonde représentent peu d’appels, mais profitent elles aussi des 30 % de réduction sur les modèles Claude.

En résumé

Une réécriture de rétro-ingénierie repose en réalité sur seulement deux piliers : le modèle formule l’hypothèse, assert rend le verdict.

Le modèle est un générateur de soupçons, nourri de toutes les implémentations publiques qu’il a vues. Il peut vous dire en quelques secondes : « cette partie a peut-être été modifiée », mais il ne sait jamais s’il a raison dans ce cas précis. Le test différentiel est la machine qui transforme ce « peut-être » en « oui » ou « non » : les entrées limites font émerger les branches, la double exécution de la même entrée révèle les sources d’entropie, l’analyse couche par couche localise la couche fautive, et la fixture distingue « je l’ai mal réécrit » de « ils ont changé l’implémentation ».

Chaque conclusion issue du processus en quatre étapes ou de l’analyse des divergences doit finir par franchir ce filtre. Ce que dit le modèle ne compte pas ; seul ce que dit assert compte.