事前に正しい出力がわからない関数をテストする必要がある。経路最適化器、感情分類器、物理シミュレーション。Metamorphic testingについて読んだ:入力と出力の間に成り立つはずのrelationを見つけ、正確な値の代わりにそれらのrelationをテストするのだ。

問題はrelationを思いつくことだ。関数シグネチャを見つめても、頭は真っ白だ。

そこでLLMに尋ねる。数秒で10個のmetamorphic relationsを返してくる。ほとんどはもっともらしい。いくつかは明らかに間違っている。1つか2つは本当に賢い。LLMは有用なブレインストーミングパートナーだが、ドメイン知識の代替ではない。それらのrelationをチェックせずにテストスイートに貼り付ければ、壊れたコードでパスしたり、正しいコードで失敗したりするテストをリリースすることになる。

LLMが実際に与えてくれるもの

LLMに関数のmetamorphic relationsを提案してもらうと、それはtraining dataに対する高度なpattern matchingを行っている。何千ものソートアルゴリズムの実装、幾何ライブラリ、機械学習パイプラインを見てきた。「定数を加えると平均が同じ定数だけシフトする」という性質が一般的であることを認識し、それを提案するのだ。

これは理解ではない。統計的な連想だ。LLMは、あなたの関数が空のリスト、NaN値、整数オーバーフローを扱うかどうかはわからない。以前に類似のものを見たので、そのrelationを提案するのだ。

典型的なPromptとResponseがどう見えるか、以下に示す。LLMに関数を渡して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)

これらのうち3つは正しく有用だ。1つは微妙に間違っている。1つは自明に真だが、あまりにも緩くてほとんどバグを捉えない。

単調性のrelationは正しいが、重複値の扱いには注意が必要だ。スケーリングのrelationは k <= 0 で失敗する。なぜならソート順が反転するからだ。平行移動のrelationは堅実だ。順列不変性は、ソートを覚えたかどうかを主にテストする。境界のrelationは、あなたの補間が0番目と100番目のパーセンタイルをminとmaxとして扱う場合にのみ正しい。

LLMは、これらのいずれについても警告しない。5つすべてを等しい自信を持って提示する。

LLM生成のRelationsをフィルタリングする

有用なワークフローは「LLMに尋ねる、出力をコピーする、昼食に行く」ではない。「LLMに尋ねる、出力を候補リストとして扱う、各候補を推論とテストで検証する」なのだ。

第一のステップは、提案されたrelationをタイプ別に分類することだ。順列不変性や冪等性のような構造的relationは、ドメインセマンティクスに依存しにくいため、安全になりがちだ。スケーリングや平行移動のような算術的relationは、成り立てば強力だが、LLMが考慮しなかったエッジケースでしばしば失敗する:負の乗数、空のコレクション、浮動小数点の丸め。

第二のステップは、反例探しだ。各提案されたrelationに対し、それが失敗する入力を見つけようとする。これが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

スケーリングのrelationは線形補間では偶然成り立つが、それを知っているのはテストしたからだ。LLMは知らなかった。Pattern matchingに基づいて推測しただけだ。異なるパーセンタイルアルゴリズムでは、スケーリングは明らかな方法で破れる。

第三のステップはmutation testingだ。Relationをテストとして持ったら、mutation testingツールをそれに対して実行する。Mutantが生き残れば、relationは弱すぎる。正しいコードがkillされるなら、relationは間違っている。

LLMが輝く場面と失敗する場面

LLMは、よく踏まれたドメインでのrelation発見において本当に有用だ。画像分類器は水平反転に不変であるべきだ、ソートアルゴリズムは冪等であるべきだ、行列乗算は加算に対して分配的であるべきだ、といった正準的なrelationを即座に思い出す。

Training dataに現れない暗黙の制約を持つドメインでは、あまり有用ではない。地域割引やプロモーションコードの相互作用に関するビジネスルールを持つカスタム価格設定エンジンをテストする場合、LLMは何も知らない。ビジネスロジックを無視する一般的な算術relationを提案するか、あるいはそれと矛盾するrelationを提案する。

失敗モードは予測可能だ:

過度に一般的なrelations。 LLMは、確率を返す関数に対して「出力は正であるべきだ」と提案する。それは弱い健全性チェックであって、metamorphic relationではない。クラッシュは捉えるが、ロジックバグは捉えない。

連続性を仮定するrelations。 LLMは、小さな入力変化が小さな出力変化を生むと提案する。閾値関数や離散分類器では失敗する。

型制約を無視するrelations。 LLMは、dataclassのリストをフィールドでソートし、最初の要素のフィールドが最小値であることをチェックすることを提案する。一部のフィールドがoptionalであるか、比較演算子がその型に定義されていないことを忘れている。

数学的に誤ったrelations。 LLMはかつて、連結リストの中央値が部分リストの中央値の平均に等しいと提案した。それは真ではない。LLMは、順列不変性と同じ自信を持ってそれを提示した。

実用的なワークフロー

LLMにあなたの脳を代替してほしいのではない。白紙を見つめる時間を加速してほしいのだ。

関数の1段落の説明から始める。型、制約、既知のエッジケースを含める。与える文脈が多ければ多いほど、LLMは幻覚しにくくなる。

タイプ別にrelationsを求める:invariance relations、monotonicity relations、additive relations、structural relations。このフレームワークはLLMのpattern matchingを整理し、より一貫性のある出力を生み出す。

各提案されたrelationに対し、次のチェックリストを実行する:

  1. 空の入力でも成り立つか?
  2. 単一要素の入力でも成り立つか?
  3. 負の値、ゼロ、NaN、無限大でも成り立つか?
  4. 入力がすでにソート済みのときは? 逆順のときは?
  5. このrelationが成り立つ場合にのみmutantをkillするmutation testを書けるか?

5つすべてを生き残ったrelationだけを残す。残りは捨て、理由を文書化する。

私たちが内部的に使っているPromptのテンプレートを以下に示す:

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では、LLMは創造性が低くなるが一貫性が高まる。Metamorphic relationsには、一貫性が創造性を上回る。賢くて間違ったrelationsではなく、退屈で正しいrelationsがほしいのだ。

本当のボトルネックは依然としてあなただ

LLMは発見を加速できるが、検証を代替することはできない。あなたが個人的に検証していないmetamorphic relationは、テストではない。アサーションに身を包んだ推測に過ぎない。

「LLMがtest oracleを発見してくれるか?」という問いに対する正直な答えは、部分的だ。候補を発見し、記憶を刺激し、エッジケースを提案できる。あなたの特定の実装、あなたの特定の制約、あなたの特定のドメインでどのrelationsが正しいかを教えてくれることはできない。

その部分は依然として、コードを理解する人間を必要とする。LLMはブレインストーミングパートナーであって、oracleのためのoracleではない。

ゼロから始めるなら、oracleが弱い関数を1つ選び、LLMに5つのrelationsを求め、各relationを破壊しようとして20分費やす。生き残ったrelationsがあなたの種セットだ。破壊されたものは、LLMが教えてくれる以上にあなたの関数について教えてくれる。