내 하니스는 서로 다른 세 가지 실패에 하나의 라벨을 붙이고 있었다 — 에러 명명이 만드는 오독
LLM의 tool call을 검증하는 자체 하니스에서, '파싱 불가', '비교 불일치', '비교 자체 실패'라는 세 가지 다른 실패가 전부 EXEC_ARGUMENTS_MISMATCH 하나로 기록되던 문제를 다룬 글입니다. 저자는 catch 분리라는 단순한 수정 너머로, 실패 이름이 조용히 삼켜지는 화이트리스트 결함과 테스트가 자기 버그를 옹호하게 되는 함정까지 파고듭니다.
본문
LLM이 생성한 tool call을 미리 동결해 둔 기대값과 비교해 failure reason을 기록하는 자체 검증 하니스에서, 저자는 서로 다른 세 가지 실패가 전부 같은 이름으로 영수증에 찍힌다는 사실을 발견했습니다. 세 개의 minimal fixture — 도착한 arguments를 parser가 읽지 못하는 경우, 읽을 수 있지만 기대값과 다른 경우, 저자 측 canonicalizer가 거부해 비교가 끝나지 않는 경우 — 를 같은 reducer에 통과시켜도 중간 라인은 항상 EXEC_ARGUMENTS_MISMATCH였습니다.
이름에 주어가 없으면 독자가 채워 넣는다
댓글러 pm25coder의 지적이 이 글의 출발점입니다. "EXEC_ARGUMENTS_MISMATCH misreads as a verdict on the model precisely because its name carries no subject." 라벨은 'arguments'라는 객체를 가리키지만, 독자는 그것을 모델에 대한 판정으로 읽습니다. 실제로는 거부된 arguments, 완료된 비교가 찾은 차이, 끝나지 않은 비교라는 세 관찰이 한 이름 아래 도착하고 있었고, 저자는 이를 "검사 단계의 실패가 검사 대상의 편차처럼 읽히는" 결함이라고 부릅니다.
원인은 세 가지 일을 하던 하나의 try 블록이었습니다. parseStrictJson이 던지는 경우와 canonicalJsonBytes가 던지는 경우가 같은 catch에 떨어졌고, 그 catch는 무조건 arguments의 이름을 붙였습니다. 수정은 parse와 compare를 분리해 각각 EXEC_ARGUMENTS_INVALID와 EXEC_COMPARATOR_ERROR를 부여한 것입니다. 같은 fixture를 다시 돌리면 세 라인 중 가운데만 바뀌어 각각 INVALID, MISMATCH, COMPARATOR_ERROR로 갈립니다. 변경은 소스 세 파일과 테스트에 걸쳐 있으며 커밋 dd1a654이고, pm25coder는 이를 "a rename, not a redesign"이라고 불렀습니다.
이름보다 더 나쁜 결함 — 조용히 삼키는 필터
수정 과정에서 더 심각한 문제가 드러납니다. failure reason은 출력 전에 ordered array로 필터링되는데, 이 네임스페이스에 레지스트리가 두 개 있고 실패 방식이 다릅니다. 26개 코드를 담은 live.mjs는 FAILURE_ORDER.filter(...)로 걸러낼 뿐 가드가 없어, 등록되지 않은 reason이 조용히 사라집니다. 새 이름을 코드에만 추가하고 배열에 등록하지 않으면 그 실패만 보이지 않게 되고 run 자체는 green으로 가지 않아 알아채기 어렵습니다. 반대로 더 좁은 20개짜리 pr2/constants.mjs는 모르는 reason에 TypeError를 던집니다. 표면이 넓은 쪽이 조용히 실패하는 것은 거꾸로 됐다는 것이 저자의 판단이며, 네이밍 패치에 레지스트리 리팩터를 섞으면 리뷰가 어려워지므로 별도 변경으로 미뤘다고 밝힙니다. 같은 collapse를 하던 세 번째 catch site도 알고 있던 두 곳을 믿지 않고 이름이 추가된 모든 위치를 grep해서야 찾았다고 합니다.
테스트 설계 — 왜 control이 필요한가
test/exec-comparator-error.test.mjs의 여섯 테스트를 parent 커밋에 대해 돌리면 다섯이 실패하고, 패치에서는 전부 통과합니다. 양쪽에서 통과하는 하나는 의도적인 control입니다. catch를 둘로 나누면서 mismatch 경로가 넘어야 할 경계가 두 개 생겼는데, 새 이름 두 개만 assert하는 suite는 경계가 삼킨 케이스를 assert하지 못해 그대로 green일 수 있습니다. control은 원래 경로가 원래 이름을 내지 못하는 순간 실패하도록 놓여 있습니다. comparator 실패는 기다리는 것이 아니라 유도합니다. 기대 객체에 BigInt를 넣는데, 이는 canonicalizer가 null·boolean·safe integer·NFC string·array·plain object라는 닫힌 집합만 받고 나머지를 거부하도록 설계됐기 때문입니다 — 출력이 해시되므로 canonical form에 대안이 있으면 안 됩니다. digest read 실패 두 건은 throwing getter로 유도하며, 의도한 read에 실제로 도달했고 에러가 최종 필터를 살아남는지까지 assert합니다. 커밋 메시지에 'three of the six tests fail'이라 쓴 것은 파일에 테스트가 네 개일 때의 stale 문장이라, 공개 SHA를 force-push로 고치지 않고 본문에 정정 단락을 남겼습니다.
저자가 스스로 범한 실수들
첫 수리는 collapse를 반대 방향으로 했습니다. catch 전체를 EXEC_COMPARATOR_ERROR로 옮겨 읽을 수 없는 도착 args가 저자 기계의 실패로 보고됐습니다. 그것이 리뷰를 통과한 이유가 더 중요한데, 저자는 "구현을 쓴 뒤 구현이 한 것을 assert하는 테스트를 썼다"고 고백합니다. 검증되지 않은 출력에서 유도된 assertion은 실패할 수 없으므로, 그 워크플로는 자기 버그를 코드화하는 것을 보장합니다. 또 한 catch를 dead code라고 주장했다가, manifest digest의 실패하는 read가 try 안에 있어 도달 가능하다는 것을 amendment에서 정정했습니다 — 하나의 input class를 전체 입력으로 일반화한 오류였습니다.
라벨이 말할 수 있는 것과 없는 것
pm25coder의 더 큰 제안은 args_mismatch_under_contract=<id>처럼 이름이 비교의 근거인 contract를 밝히라는 것이었고, 이는 아직 구현되지 않았습니다. EXEC_ARGUMENTS_INVALID도 누가 나쁜 arguments를 만들었는지가 아니라 경계만 표시합니다 — model, relay, transport 모두 여전히 용의자이며, 이름에 생산자를 넣으면 같은 결함을 더 친근한 라벨로 반복하는 셈입니다. 운영자가 모델이 garbage를 내는지 알고 싶어한다는 비용은 인정하지만, 해법은 이름으로 추측하는 것이 아니라 model-event 경계에서 관찰된 arguments, 중간의 relay·transport 변형, 지배 contract 같은 provenance를 보존해 사후에 귀속할 수 있게 하는 것이라고 말합니다. EXEC_COMPARATOR_ERROR가 원인이 아니라 stage를 이름 삼는다는 한계에는 업데이트가 붙었습니다. 2026-09-15자로 Vinh Nguyen이 expected side를 단독으로 canonicalize하면 어느 operand가 실패했는지 구분 가능함을 보여, 얼어붙은 fixture가 나쁜 경우는 EXEC_EXPECTATION_INVALID로 보고하게 됐습니다(872507f, 두 reducer 모두). 다만 model call 이후에 실행되므로 prepare 시점 검증이 맞는 버전이며 아직 쓰이지 않았다고 덧붙입니다.
저자가 남긴 질문은 이것입니다. 시스템이 failure name을 쓰는 모든 곳을 찾아 그 이름이 무엇에 대한 진술인지 묻고, 그것이 party가 아니라 thing을 가리킨다면 같은 branch에 무엇이 더 떨어지는지 확인하라는 것. "When this fires, whose fault does a reader assume it is, and is that always true?" 검증하고 싶은 독자를 위해 저장소 clone부터 parent 파일을 checkout해 5 fail·1 pass를 재현하는 명령까지 전부 제공되며, 마지막 실행에서 계속 통과하는 하나가 control입니다.
원문: dev.to / 번역·요약: Trawling