Vous devez tester une fonction où la sortie correcte est impossible à connaître à l’avance. Un optimiseur d’itinéraire. Un classificateur de sentiment. Une simulation physique. Vous avez lu sur le metamorphic testing : trouvez des relations entre les entrées et les sorties qui doivent être vraies, puis testez ces relations au lieu des valeurs exactes.

Le problème est de trouver les relations. Vous fixez la signature de votre fonction et vous avez un blanc.

Alors vous demandez à un LLM. Il renvoie dix metamorphic relations en quelques secondes. La plupart semblent plausibles. Quelques-unes sont évidemment fausses. Une ou deux sont véritablement astucieuses. Le LLM est un partenaire de brainstorming utile, mais ce n’est pas un substitut à la connaissance du domaine. Collez ces relations dans votre suite de tests sans vérification, et vous livrerez des tests qui passent sur du code cassé ou échouent sur du code correct.

Ce que le LLM vous donne réellement

Quand vous demandez à un LLM de proposer des metamorphic relations pour une fonction, il fait de la pattern matching avancée à travers les données d’entraînement. Il a vu des milliers d’implémentations d’algorithmes de tri, de bibliothèques géométriques, et de pipelines d’apprentissage automatique. Il reconnaît que « ajouter une constante devrait décaler la moyenne de la même constante » est une propriété commune, et il la suggère.

Ce n’est pas de la compréhension. C’est de l’association statistique. Le LLM ne sait pas si votre fonction gère les listes vides, les valeurs NaN, ou les débordements d’entiers. Il propose la relation parce qu’il en a vu de similaires auparavant.

Voici à quoi ressemble une invite et une réponse typiques. Vous donnez au LLM une fonction et lui demandez des relations :

# Your function under test
def percentile(values: list[float], p: float) -> float:
    """Return the p-th percentile using linear interpolation."""
    if not values:
        raise ValueError("empty list")
    if not 0.0 <= p <= 1.0:
        raise ValueError("p must be in [0, 1]")
    sorted_vals = sorted(values)
    n = len(sorted_vals)
    idx = p * (n - 1)
    lower = int(idx)
    upper = lower + 1
    if upper >= n:
        return sorted_vals[-1]
    frac = idx - lower
    return sorted_vals[lower] * (1 - frac) + sorted_vals[upper] * frac
Prompt: Propose 5 metamorphic relations for this percentile function.

Response:
1. Monotonicity: if p1 < p2, then percentile(values, p1) <= percentile(values, p2)
2. Scaling: percentile([v * k for v in values], p) == percentile(values, p) * k
3. Translation: percentile([v + c for v in values], p) == percentile(values, p) + c
4. Permutation invariance: percentile(values, p) == percentile(shuffled(values), p)
5. Boundary: percentile(values, 0.0) == min(values), percentile(values, 1.0) == max(values)

Trois d’entre elles sont correctes et utiles. Une est subtilement fausse. Une est trivialement vraie mais si lâche qu’elle n’attrape presque aucun bug.

La relation de monotonicité est correcte, bien que vous deviez gérer les valeurs dupliquées. La relation de mise à l’échelle échoue pour k <= 0 parce que l’ordre de tri s’inverse. La relation de translation est solide. L’invariance par permutation teste surtout si vous avez pensé à trier. La relation de limite n’est correcte que si votre interpolation traite les percentiles 0e et 100e comme min et max.

Le LLM ne vous avertit de rien de tout cela. Il présente les cinq avec une confiance égale.

Comment filtrer les relations générées par LLM

Le flux de travail utile n’est pas « demander au LLM, copier la sortie, aller déjeuner ». C’est « demander au LLM, traiter la sortie comme une liste de candidats, puis vérifier chaque candidat avec du raisonnement et des tests ».

La première étape consiste à classer chaque relation proposée par type. Les relations structurelles, comme l’invariance par permutation ou l’idempotence, ont tendance à être plus sûres parce qu’elles dépendent moins des sémantiques de domaine. Les relations arithmétiques, comme la mise à l’échelle ou la translation, sont puissantes quand elles sont vraies, mais elles échouent souvent sur des cas limites que le LLM n’a pas considérés : multiplicateurs négatifs, collections vides, arrondis en virgule flottante.

La deuxième étape est la chasse aux contre-exemples. Pour chaque relation proposée, essayez de trouver une entrée où elle échoue. C’est le moyen le plus rapide de repérer les hallucinations de LLM.

def test_percentile_scaling_counterexample():
    """The LLM proposed scaling. It fails for negative k."""
    values = [1.0, 2.0, 3.0, 4.0]
    original = percentile(values, 0.5)  # 2.5
    
    k = -1.0
    scaled_values = [v * k for v in values]
    scaled_result = percentile(scaled_values, 0.5)  # -2.5
    
    # The relation holds here by accident. For nearest-rank,
    # multiplying by a negative flips sort order and breaks it.
    assert abs(scaled_result - original * k) < 1e-9

La relation de mise à l’échelle se trouve être vraie pour l’interpolation linéaire, mais vous le savez seulement parce que vous l’avez testée. Le LLM ne le savait pas. Il a deviné basé sur du pattern matching. Un algorithme de percentile différent casserait la mise à l’échelle de manières évidentes.

La troisième étape est le mutation testing. Une fois que vous avez une relation comme test, exécutez un outil de mutation testing sur elle. Si des mutants survivent, votre relation est trop faible. Si du code correct est tué, votre relation est fausse.

Où les LLM brillent et où ils échouent

Les LLM sont véritablement utiles pour découvrir des relations dans des domaines bien connus. Ils savent que les classificateurs d’images devraient être invariants aux retournements horizontaux, que les algorithmes de tri devraient être idempotents, et que la multiplication matricielle devrait se distribuer sur l’addition. Le LLM se souvient de ces relations canoniques instantanément.

Ils sont moins utiles dans des domaines avec des contraintes implicites qui n’apparaissent pas dans les données d’entraînement. Si vous testez un moteur de tarification personnalisé avec des règles métier sur les remises régionales et les interactions de codes promotionnels, le LLM n’en a aucune idée. Il proposera des relations arithmétiques génériques qui ignorent la logique métier, ou pire, suggérera des relations qui la contredisent.

Les modes de défaillance sont prévisibles :

Relations trop générales. Le LLM suggère « la sortie devrait être positive » pour une fonction qui retourne une probabilité. C’est une vérification de cohérence faible, pas une metamorphic relation. Elle attrape les plantages mais pas les bugs de logique.

Relations qui supposent la continuité. Le LLM propose que de petits changements d’entrée produisent de petits changements de sortie. Cela échoue pour les fonctions de seuil et les classificateurs discrets.

Relations qui ignorent les contraintes de type. Le LLM suggère de trier une liste de dataclasses par un champ, puis de vérifier que le champ du premier élément est le minimum. Il oublie que certains champs pourraient être optionnels, ou que l’opérateur de comparaison pourrait ne pas être défini pour ce type.

Relations qui sont mathématiquement fausses. Le LLM a un jour suggéré que la médiane d’une liste concaténée égale la moyenne des médianes des sous-listes. Ce n’est pas vrai. Le LLM l’a présenté avec la même confiance que l’invariance par permutation.

Un flux de travail pratique

Ne demandez pas au LLM de remplacer votre cerveau. Demandez-lui d’accélérer la partie où vous fixez une page blanche.

Commencez par écrire une description d’un paragraphe de votre fonction, incluant les types, les contraintes, et les cas limites connus. Plus vous donnez de contexte, moins le LLM hallucine.

Demandez des relations catégorisées par type : relations d’invariance, relations de monotonicité, relations additives, et relations structurelles. Ce cadrage aide le LLM à organiser son pattern matching et produit une sortie plus cohérente.

Pour chaque relation proposée, parcourez cette liste de vérification :

  1. Est-elle vraie pour des entrées vides ?
  2. Est-elle vraie pour des entrées à un seul élément ?
  3. Est-elle vraie pour des valeurs négatives, zéro, NaN, ou l’infini ?
  4. Est-elle vraie quand l’entrée est déjà en ordre trié ? En ordre inverse ?
  5. Puis-je écrire un mutation test qui tue des mutants seulement si cette relation est vraie ?

Gardez les relations qui survivent aux cinq. Jetez le reste, et documentez pourquoi.

Voici un modèle pour l’invite que nous utilisons en interne :

SYSTEM_PROMPT = """
You are a testing assistant. Given a Python function, propose metamorphic relations.
For each relation:
1. State the relation clearly
2. Identify the type: invariance, monotonicity, additive, or structural
3. List edge cases where it might fail
4. Rate confidence as HIGH, MEDIUM, or LOW
"""

def generate_relations(source_code: str) -> list[dict]:
    response = openai.chat.completions.create(
        model="gpt-4o",
        messages=[
            {"role": "system", "content": SYSTEM_PROMPT},
            {"role": "user", "content": f"Propose relations:\n\n{source_code}"}
        ],
        temperature=0.3,
    )
    return parse_relation_candidates(response.choices[0].message.content)

À 0,3, le LLM est moins créatif mais plus cohérent. Pour les metamorphic relations, la cohérence bat la créativité. Vous voulez les relations ennuyeuses et correctes, pas les relations astucieuses et fausses.

Le vrai goulot d’étranglement, c’est toujours vous

Les LLM peuvent accélérer la découverte, mais ils ne peuvent pas remplacer la vérification. Une metamorphic relation que vous n’avez pas validée personnellement n’est pas un test. C’est une supposition habillée en assertion.

La réponse honnête à « un LLM peut-il découvrir des test oracles pour moi ? » est partielle. Il peut découvrir des candidats, rafraîchir votre mémoire, et suggérer des cas limites. Il ne peut pas vous dire quelles relations sont correctes pour votre implémentation spécifique, avec vos contraintes spécifiques, dans votre domaine spécifique.

Cette partie nécessite encore un humain qui comprend le code. Le LLM est un partenaire de brainstorming, pas un oracle pour les oracles.

Si vous partez de zéro, choisissez une fonction avec un oracle faible, demandez à un LLM cinq relations, puis passez vingt minutes à essayer de casser chacune. Les relations qui survivent sont votre ensemble de départ. Celles qui cassent vous apprennent plus sur votre fonction que le LLM ne le pourrait jamais.