Necesitas probar una función donde la salida correcta es imposible de conocer de antemano. Un optimizador de rutas. Un clasificador de sentimiento. Una simulación de física. Has leído sobre metamorphic testing: encontrar relaciones entre entradas y salidas que deben mantenerse, y luego probar esas relaciones en lugar de valores exactos.

El problema es idear las relaciones. Miras la firma de tu función y te quedas en blanco.

Así que le preguntas a un LLM. Te devuelve diez metamorphic relations en segundos. La mayoría parecen plausibles. Algunas son obviamente incorrectas. Una o dos son genuinamente inteligentes. El LLM es un socio útil de lluvia de ideas, pero no es un sustituto del conocimiento de dominio. Pega esas relaciones en tu suite de pruebas sin revisar, y desplegarás pruebas que pasan con código roto o fallan con código correcto.

Lo que el LLM realmente te da

Cuando le pides a un LLM que proponga metamorphic relations para una función, está haciendo pattern matching avanzado a través de datos de entrenamiento. Ha visto miles de implementaciones de algoritmos de ordenamiento, bibliotecas geométricas y pipelines de aprendizaje automático. Reconoce que “añadir una constante debería desplazar la media por la misma constante” es una propiedad común, y la sugiere.

Esto no es comprensión. Es asociación estadística. El LLM no sabe si tu función maneja listas vacías, valores NaN o desbordamiento de enteros. Propone la relación porque ha visto otras similares antes.

Así es cómo se ve un prompt y respuesta típicos. Le das al LLM una función y pides relaciones:

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

Tres de estas son correctas y útiles. Una es sutilmente incorrecta. Una es trivialmente verdadera pero tan floja que atrapa casi ningún bug.

La relación de monotonicity es correcta, aunque necesitas manejar valores duplicados. La relación de scaling falla para k <= 0 porque el orden de ordenamiento se invierte. La relación de translation es sólida. Permutation invariance principalmente prueba si recordaste ordenar. La relación de boundary es correcta solo si tu interpolación trata los percentiles 0 y 100 como min y max.

El LLM no te advierte sobre nada de esto. Presenta las cinco con la misma confianza.

Cómo filtrar relaciones generadas por LLM

El flujo de trabajo útil no es “pregúntale al LLM, copia la salida, vete a comer.” Es “pregúntale al LLM, trata la salida como una lista de candidatos, y luego verifica cada candidato con razonamiento y pruebas.”

El primer paso es clasificar cada relación propuesta por tipo. Las relaciones estructurales, como permutation invariance o idempotence, tienden a ser más seguras porque dependen menos de la semántica de dominio. Las relaciones aritméticas, como scaling o translation, son poderosas cuando se mantienen, pero a menudo fallan en casos límite que el LLM no consideró: multiplicadores negativos, colecciones vacías, redondeo de punto flotante.

El segundo paso es la caza de contraejemplos. Para cada relación propuesta, intenta encontrar una entrada donde falle. Esta es la forma más rápida de detectar alucinaciones 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 relación de scaling resulta mantenerse para interpolación lineal, pero solo lo sabes porque la probaste. El LLM no lo sabía. Adivinó basándose en pattern matching. Un algoritmo de percentil diferente rompería scaling de formas obvias.

El tercer paso es mutation testing. Una vez que tienes una relación como prueba, ejecuta una herramienta de mutation testing contra ella. Si los mutantes sobreviven, tu relación es demasiado débil. Si el código correcto se mata, tu relación está equivocada.

Dónde los LLMs brillan y dónde fracasan

Los LLMs son genuinamente útiles para descubrir relaciones en dominios bien transitados. Saben que los clasificadores de imágenes deberían ser invariantes a volteos horizontales, que los algoritmos de ordenamiento deberían ser idempotentes, y que la multiplicación de matrices debería distribuir sobre la suma. El LLM recuerda estas relaciones canónicas al instante.

Son menos útiles en dominios con restricciones implícitas que no aparecen en los datos de entrenamiento. Si estás probando un motor de precios personalizado con reglas de negocio sobre descuentos regionales e interacciones de códigos promocionales, el LLM no tiene idea. Propondrá relaciones aritméticas genéricas que ignoran la lógica de negocio, o peor, sugerirá relaciones que la contradicen.

Los modos de falla son predecibles:

Relaciones excesivamente generales. El LLM sugiere “la salida debería ser positiva” para una función que devuelve una probabilidad. Esa es una comprobación de sanidad débil, no una metamorphic relation. Atrapa crashes pero no bugs de lógica.

Relaciones que asumen continuidad. El LLM propone que pequeños cambios de entrada producen pequeños cambios de salida. Eso falla para funciones de umbral y clasificadores discretos.

Relaciones que ignoran type constraints. El LLM sugiere ordenar una lista de dataclasses por un campo, y luego comprobar que el campo del primer elemento es el mínimo. Olvida que algunos campos podrían ser opcionales, o que el operador de comparación podría no estar definido para ese type.

Relaciones que son matemáticamente falsas. El LLM una vez sugirió que la mediana de una lista concatenada es igual al promedio de las medianas de las sublistas. Eso no es cierto. El LLM lo presentó con la misma confianza que permutation invariance.

Un flujo de trabajo práctico

No le pidas al LLM que reemplace tu cerebro. Pídele que acelere la parte donde miras una página en blanco.

Empieza escribiendo un párrafo de descripción de tu función, incluyendo types, restricciones y casos límite conocidos. Cuanto más contexto des, menos alucinará el LLM.

Pide relaciones categorizadas por tipo: relaciones de invarianza, relaciones de monotonicity, relaciones aditivas y relaciones estructurales. Este encuadre ayuda al LLM a organizar su pattern matching y produce una salida más consistente.

Para cada relación propuesta, ejecuta esta lista de verificación:

  1. ¿Se mantiene para entradas vacías?
  2. ¿Se mantiene para entradas de un solo elemento?
  3. ¿Se mantiene para valores negativos, cero, NaN o infinito?
  4. ¿Se mantiene cuando la entrada ya está en orden ascendente? ¿En orden descendente?
  5. ¿Puedo escribir una prueba de mutation testing que mate mutantes solo si se mantiene esta relación?

Quédate con las relaciones que sobrevivan las cinco. Descarta el resto, y documenta por qué.

Aquí tienes una plantilla para el prompt que usamos internamente:

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)

A 0.3, el LLM es menos creativo pero más consistente. Para metamorphic relations, la consistencia vence a la creatividad. Quieres las relaciones aburridas y correctas, no las inteligentes y equivocadas.

El cuello de botella real sigues siendo tú

Los LLMs pueden acelerar el descubrimiento, pero no pueden reemplazar la verificación. Una metamorphic relation que no has validado personalmente no es una prueba. Es una conjetura disfrazada de afirmación.

La respuesta honesta a “¿puede un LLM descubrir test oracles para mí?” es parcial. Puede descubrir candidatos, refrescar tu memoria y sugerir casos límite. No puede decirte qué relaciones son correctas para tu implementación específica, con tus restricciones específicas, en tu dominio específico.

Esa parte aún requiere un humano que entienda el código. El LLM es un socio de lluvia de ideas, no un oracle para los oracles.

Si estás empezando desde cero, elige una función con un oráculo débil, pídele a un LLM cinco relaciones, y luego pasa veinte minutos intentando romper cada una. Las relaciones que sobrevivan son tu conjunto seed. Las que se rompan te enseñarán más sobre tu función de lo que el LLM jamás podría.