Вам нужно протестировать функцию, где правильный вывод невозможно знать заранее. Оптимизатор маршрутов. Классификатор sentiment. Физическая симуляция. Вы читали о metamorphic testing: найдите соотношения между входными данными и выводами, которые должны выполняться, затем протестируйте эти соотношения вместо точных значений.

Проблема в том, чтобы придумать соотношения. Вы смотрите на сигнатуру функции и впадаете в ступор.

И вы спрашиваете LLM. Он выплёвывает десять metamorphic relations за секунды. Большинство выглядит правдоподобно. Некоторые очевидно неверны. Одно или два по-настоящему умны. LLM — полезный партнёр для brainstorming, но не замена domain knowledge. Вставьте эти соотношения в test suite без проверки — и вы выкатите тесты, которые проходят на сломанном коде или падают на корректном.

Что LLM на самом деле даёт вам

Когда вы просите LLM предложить metamorphic relations для функции, он выполняет продвинутый pattern matching по обучающим данным. Он видел тысячи реализаций алгоритмов сортировки, геометрических библиотек и ML-пайплайнов. Он распознаёт, что «добавление константы должно сдвигать среднее на ту же константу» — распространённое свойство, и предлагает его.

Это не понимание. Это статистическая ассоциация. LLM не знает, обрабатывает ли ваша функция пустые списки, NaN-значения или integer overflow. Он предлагает соотношение, потому что видел похожие раньше.

Вот как выглядит типичный prompt и ответ. Вы скармливаете LLM функцию и просите соотношения:

# 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)

Три из них правильные и полезные. Одно тонко неверно. Одно тривиально верно, но такое рыхлое, что почти не ловит багов.

Соотношение монотонности правильное, хотя нужно обрабатывать дублирующие значения. Соотношение масштабирования падает для k <= 0, потому что порядок сортировки инвертируется. Соотношение сдвига надёжное. Инвариантность перестановки в основном тестирует, вспомнили ли вы отсортировать. Граничное соотношение правильно только если ваша интерполяция трактует 0-й и 100-й перцентили как min и max.

LLM не предупреждает вас ни о чём из этого. Он представляет все пять с одинаковой уверенностью.

Как фильтровать соотношения, сгенерированные LLM

Полезный workflow — это не «спроси LLM, скопируй вывод, иди на обед». Это «спроси LLM, трактуй вывод как список кандидатов, затем проверь каждого кандидата рассуждением и тестированием».

Шаг первый — классифицировать каждое предложенное соотношение по типу. Структурные соотношения, вроде инвариантности перестановки или идемпотентности, обычно безопаснее, потому что они меньше зависят от domain semantics. Арифметические соотношения, вроде масштабирования или сдвига, мощные, когда выполняются, но часто падают на крайних случаях, которые LLM не учёл: отрицательные множители, пустые коллекции, floating-point rounding.

Шаг второй — охота за контрпримерами. Для каждого предложенного соотношения попробуйте найти вход, где оно падает. Это самый быстрый способ обнаружить LLM-hallucinations.

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

Соотношение масштабирования случайно выполняется для линейной интерполяции, но вы знаете это только потому, что протестировали. LLM не знал. Он угадал на основе pattern matching. Другой алгоритм перцентиля сломал бы масштабирование очевидными способами.

Шаг третий — mutation testing. Как только у вас есть соотношение как тест, запустите mutation testing tool против него. Если мутанты выживают — ваше соотношение слишком слабое. Если корректный код убивается — ваше соотношение неверно.

Где LLM сияют, а где проваливаются

LLM действительно полезны для открытия соотношений в хорошо изведанных доменах. Они знают, что классификаторы изображений должны быть инвариантны к горизонтальным flips, что алгоритмы сортировки должны быть идемпотентны, и что умножение матриц должно дистрибутивно относительно сложения. LLM мгновенно вспоминает эти канонические соотношения.

Они менее полезны в доменах с неявными ограничениями, которые не появляются в обучающих данных. Если вы тестируете кастомный pricing engine с бизнес-правилами о региональных скидках и взаимодействиях промокодов, LLM понятия не имеет. Он предложит общие арифметические соотношения, игнорирующие бизнес-логику, или, что хуже, предложит соотношения, которые ей противоречат.

Режимы отказа предсказуемы:

Чересчур общие соотношения. LLM предлагает «вывод должен быть положительным» для функции, которая возвращает вероятность. Это слабая sanity check, а не metamorphic relation. Она ловит падения, но не логические баги.

Соотношения, предполагающие непрерывность. LLM предлагает, что малые изменения входа дают малые изменения вывода. Это падает для пороговых функций и дискретных классификаторов.

Соотношения, игнорирующие type constraints. LLM предлагает отсортировать список dataclasses по полю, затем проверить, что поле первого элемента — минимум. Он забывает, что некоторые поля могут быть optional, или что оператор сравнения может быть не определён для этого типа.

Соотношения, математически неверные. LLM однажды предложил, что медиана конкатенированного списка равна среднему медиан подсписков. Это не так. LLM представил это с той же уверенностью, что и инвариантность перестановки.

Практичный workflow

Не просите LLM заменить ваш мозг. Попросите ускорить ту часть, где вы смотрите на пустую страницу.

Начните с написания описания функции в один абзац, включая типы, ограничения и известные крайние случаи. Чем больше контекста вы дадите, тем меньше LLM hallucinate.

Попросите соотношения, категоризированные по типу: invariance relations, monotonicity relations, additive relations и structural relations. Эта рамка помогает LLM организовать его pattern matching и даёт более последовательный вывод.

Для каждого предложенного соотношения пройдите этот чеклист:

  1. Выполняется ли для пустых входных данных?
  2. Выполняется ли для входных данных с одним элементом?
  3. Выполняется ли для отрицательных значений, нуля, NaN или бесконечности?
  4. Выполняется ли, когда вход уже отсортирован? В обратном порядке?
  5. Могу ли я написать mutation test, который убивает мутантов только если это соотношение выполняется?

Оставьте соотношения, которые выживают все пять. Отбросьте остальные и задокументируйте, почему.

Вот шаблон 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 последовательность побеждает креативность. Вам нужны скучные, правильные соотношения, а не умные, неверные.

Реальное узкое место — всё ещё вы

LLM могут ускорить открытие, но не могут заменить верификацию. Metamorphic relation, которую вы лично не проверили, — это не тест. Это догадка, замаскированная под assertion.

Честный ответ на вопрос «может ли LLM открыть test oracles для меня?» — частичный. Он может открыть кандидатов, подтолкнуть память и предложить крайние случаи. Он не может сказать, какие соотношения правильны для вашей конкретной реализации, с вашими конкретными ограничениями, в вашем конкретном домене.

Эта часть всё ещё требует человека, который понимает код. LLM — партнёр для brainstorming, не oracle для oracles.

Если вы начинаете с нуля, выберите одну функцию со слабым oracle, попросите LLM дать пять соотношений, затем потратьте двадцать минут, пытаясь сломать каждое. Соотношения, которые выживут — ваш seed set. Те, которые ломаются, учат вас больше о вашей функции, чем LLM когда-либо мог.