사전에 올바른 출력을 알 수 없는 함수를 테스트해야 합니다. 경로 최적화기. 감정 분류기. 물리 시뮬레이션. metamorphic testing에 대해 읽어봤습니다: 반드시 성립해야 하는 입력과 출력 사이의 relation을 찾고, 정확한 값 대신 그 relation을 테스트합니다.
문제는 relation을 생각해내는 것입니다. 함수 시그니처를 멍하니 쳐다보지만 머릿속이 하얗게 됩니다.
그래서 LLM에게 물어봅니다. 수 초 만에 10개의 metamorphic relation을 쏟아냅니다. 대부분 그럴듯해 보입니다. 몇 개는 분명히 틀렸습니다. 한두 개는 진정으로 영리합니다. LLM은 유용한 브레인스토밍 파트너이지만, 도메인 지식의 대체재는 아닙니다. 확인하지 않고 그 relation을 테스트 스위트에 붙여넣으면, 고장 난 코드에서는 통과하고 올바른 코드에서는 실패하는 테스트를 배포하게 될 것입니다.
LLM이 실제로 주는 것
LLM에게 함수에 대한 metamorphic relation을 제안하라고 하면, 이는 훈련 데이터 전반에 걸쳐 고급 패턴 매칭을 수행하는 것입니다. 수천 개의 정렬 알고리즘 구현체, 기하학 라이브러리, 머신러닝 파이프라인을 보았습니다. ‘상수를 더하면 평균이 동일한 상수만큼 이동해야 한다’는 것이 일반적인 속성임을 인식하고 이를 제안합니다.
이것은 이해가 아닙니다. 통계적 연관입니다. LLM은 함수가 빈 리스트, NaN 값, 정수 오버플로우를 처리하는지 모릅니다. 이전에 유사한 것을 보았기 때문에 relation을 제안하는 것입니다.
전형적인 프롬프트와 응답이 어떤 모습인지 보겠습니다. LLM에 함수를 입력하고 relation을 요청합니다:
# 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)
이들 중 세 가지는 올바르고 유용합니다. 하나는 미묘하게 틀렸습니다. 하나는 자명하게 참이지만 너무 느슨하여 거의 버그를 잡지 못합니다.
단조성 relation은 올바르며, 중복 값 처리가 필요합니다. 스케일링 relation은 k <= 0에서 실패합니다. 정렬 순서가 역전되기 때문입니다. 평행 이동 relation은 확고합니다. 순열 불변성은 대부분 정렬을 기억했는지 테스트합니다. 경계 relation은 보간이 0번째와 100번째 백분위수를 최소값과 최대값으로 처리할 때만 올바릅니다.
LLM은 이것들 중 어느 것에 대해서도 경고하지 않습니다. 다섯 가지 모두를 동일한 자신감으로 제시합니다.
LLM이 생성한 Relation 필터링하기
유용한 워크플로우는 ‘LLM에게 묻고, 출력을 복사하고, 점심 먹으러 간다’가 아닙니다. ‘LLM에게 묻고, 출력을 후보 목록으로 취급하고, 그런 다음 추론과 테스트로 각 후보를 검증한다’입니다.
첫 번째 단계는 제안된 각 relation을 타입별로 분류하는 것입니다. 순열 불변성이나 멱등성과 같은 구조적 relation은 도메인 semantics에 덜 의존하므로 더 안전한 경향이 있습니다. 스케일링이나 평행 이동과 같은 산술적 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은 몰랐습니다. 패턴 매칭을 기반으로 추측했습니다. 다른 백분위수 알고리즘은 분명한 방식으로 스케일링을 깨뜨릴 것입니다.
세 번째 단계는 mutation testing입니다. relation을 테스트로 가지고 있으면, 이에 대해 mutation testing 도구를 실행하세요. mutant가 살아남으면, relation이 너무 약합니다. 올바른 코드가 죽으면, relation이 틀렸습니다.
LLM이 빛나는 곳과 실패하는 곳
LLM은 널리 알려진 도메인에서 relation을 발견하는 데 진정으로 유용합니다. 이미지 분류기가 수평 뒤집기에 대해 불변해야 한다는 것, 정렬 알고리즘이 멱등해야 한다는 것, 행렬 곱셈이 덧셈에 대해 분배 법칙을 만족해야 한다는 것을 압니다. LLM은 이러한 전형적인 relation을 즉시 상기합니다.
훈련 데이터에 나타나지 않는 암묵적 제약이 있는 도메인에서는 덜 유용합니다. 지역 할인과 프로모션 코드 상호작용에 대한 비즈니스 규칙이 있는 커스텀 가격 책정 엔진을 테스트하는 경우, LLM은 전혀 모릅니다. 비즈니스 로직을 무시하는 일반적인 산술적 relation을 제안하거나, 더 나쁘게는 이와 모순되는 relation을 제안할 것입니다.
실패 모드는 예측 가능합니다:
너무 일반적인 relation. LLM은 확률을 반환하는 함수에 대해 ‘출력은 양수여야 한다’를 제안합니다. 이는 약한 sanity check이며, metamorphic relation이 아닙니다. 충돌은 잡지만 로직 버그는 잡지 못합니다.
연속성을 가정하는 relation. LLM은 작은 입력 변화가 작은 출력 변화를 낳는다고 제안합니다. 임계값 함수와 이산 분류기에서는 이는 실패합니다.
타입 제약을 무시하는 relation. LLM은 dataclass 리스트를 필드별로 정렬한 다음, 첫 번째 요소의 필드가 최소값인지 확인하라고 제안합니다. 일부 필드가 선택적일 수 있거나, 해당 타입에 대해 비교 연산자가 정의되지 않았을 수 있다는 것을 잊습니다.
수학적으로 거짓인 relation. LLM이 한때 연결된 리스트의 중앙값이 부분 리스트 중앙값의 평균과 같다고 제안한 적이 있습니다. 사실이 아닙니다. LLM은 순열 불변성과 동일한 자신감으로 이를 제시했습니다.
실용적인 워크플로우
LLM에게 당신의 뇌를 대체하라고 하지 마세요. 하얀 페이지를 멍하니 쳐다보는 부분을 가속화하라고 하세요.
함수에 대한 한 단락 설명을 작성하는 것으로 시작하세요. 타입, 제약, 알려진 엣지 케이스를 포함하세요. 더 많은 맥락을 제공할수록 LLM의 환각은 줄어듭니다.
타입별로 분류된 relation을 요청하세요: 불변성 relation, 단조성 relation, 가산적 relation, 구조적 relation. 이러한 틀은 LLM이 패턴 매칭을 조직화하고 더 일관된 출력을 생성하도록 돕습니다.
제안된 각 relation에 대해 다음 체크리스트를 실행하세요:
- 빈 입력에서 성립하는가?
- 단일 요소 입력에서 성립하는가?
- 음수, 영, NaN, 무한대에서 성립하는가?
- 입력이 이미 정렬된 순서일 때 성립하는가? 역순일 때는?
- 이 relation이 성립할 때만 mutant를 죽이는 mutation test를 작성할 수 있는가?
다섯 가지 모두에서 살아남은 relation을 유지하세요. 나머지는 폐기하고 이유를 문서화하세요.
내부적으로 사용하는 프롬프트 템플릿은 다음과 같습니다:
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 relation의 경우, 일관성이 창의성을 이깁니다. 영리하지만 틀린 relation이 아니라, 지루하지만 올바른 relation을 원합니다.
진정한 병목은 여전히 당신이다
LLM은 발견을 가속화할 수 있지만, 검증을 대체할 수는 없습니다. 개인적으로 검증하지 않은 metamorphic relation은 테스트가 아닙니다. assertion으로 꾸민 추측일 뿐입니다.
‘LLM이 나를 위해 test oracle을 발견할 수 있나요?‘에 대한 솔직한 답은 부분적입니다. 후보를 발견하고, 기억을 자극하고, 엣지 케이스를 제안할 수 있습니다. 하지만 당신의 특정 구현, 특정 제약, 특정 도메인에서 어떤 relation이 올바른지는 알려줄 수 없습니다.
그 부분은 여전히 코드를 이해하는 인간이 필요합니다. LLM은 브레인스토밍 파트너이지, oracle의 oracle이 아닙니다.
처음부터 시작한다면, 약한 oracle을 가진 함수 하나를 선택하고, LLM에게 다섯 개의 relation을 요청한 다음, 각각을 깨뜨리려고 20분을 쓰세요. 살아남은 relation이 당신의 seed set입니다. 깨진 것들은 LLM이 결코 가르쳐줄 수 없었던 당신의 함수에 대한 더 많은 것을 가르쳐줍니다.