검증에 실패한 값을 지우지 않았습니다: `verified: false`가 필요한 이유

영수증에서 purchaseDate: 1999-01-01을 추출했다고 해보겠습니다.
모델이 근거라고 내민 줄은 BLUE BOTTLE입니다.
날짜는 어디에도 없습니다.
이때 API가 할 수 있는 일은 대략 셋입니다.
- 값을 그대로 믿는다.
- 값을 버린다.
- 값을 남기되 믿으면 안 된다고 표시한다.
첫 번째는 곤란합니다.
영수증에 없는 날짜를 사실처럼 내보내기 때문입니다.
두 번째는 깔끔해 보입니다.
하지만 사용자는 모델이 뭔가 읽었으나 검증에 실패했다는 사실조차 볼 수 없습니다.
제가 receipt-evidence에서 선택한 것은 세 번째였습니다.
{
"value": "1999-01-01",
"source": "model",
"evidence": {
"pageIndex": 0,
"excerpt": "BLUE BOTTLE"
},
"verified": false
}
응답의 unverified 배열에는 경로도 함께 남깁니다.
{
"unverified": ["fields.purchaseDate"]
}
이 설계의 핵심은 “틀린 값을 친절하게 보여주자”가 아닙니다.
관측한 값과 신뢰 판단을 한 필드에 뭉개지 말자는 것입니다.
근거가 진짜인 것과 값이 진짜인 것은 다른 질문입니다
처음에는 evidence 검증을 한 문장으로 생각하기 쉽습니다.
“이 excerpt가 영수증에 있나요?”
그 질문만으로는 부족했습니다.
다음 page text를 보겠습니다.
GS25
합계 14,800
모델이 다음처럼 답할 수 있습니다.
{
"value": 13000,
"excerpt": "합계 14,800"
}
합계 14,800은 실제 page에 있습니다.
따라서 excerpt의 존재만 확인하면 통과합니다.
그렇지만 그 줄은 13,000을 말하지 않습니다.
반대쪽 실패도 있습니다.
{
"value": 148000,
"excerpt": "합계 148,000"
}
이번에는 excerpt와 value가 서로 잘 맞습니다.
문제는 그 줄 자체가 page에 없다는 것입니다.
그래서 검증을 두 질문으로 나눴습니다.
verifyEvidence: excerpt가 OCR page의 연속된 line 구간에 실제로 존재하는가?excerptContainsAmount또는excerptContainsText: 그 excerpt가 claim한 value를 실제로 진술하는가?
두 질문은 서로를 대신하지 못합니다.
구현도 두 결과를 &&로 묶습니다.
const verified = verifyEvidence(excerpt, pageText) && statesValue(excerpt, value);
첫 번째 guard가 “가짜 인용문”을 막습니다.
두 번째 guard가 “진짜 인용문에 붙인 가짜 값”을 막습니다.
하나라도 빠지면 서로 다른 형태의 fabrication이 통과합니다.
실패한 값을 삭제하지 않고 상태를 붙였습니다
검증 결과가 false일 때 resolve()는 값을 undefined로 바꾸지 않습니다.
대신 path를 unverified에 추가하고, 원래 candidate를 그대로 반환합니다.
동작을 줄이면 다음과 같습니다.
if (!verified) unverified.push(path);
return {
value,
source,
evidence,
verified,
};
이 응답에는 서로 다른 정보가 공존합니다.
value: parser나 model이 무엇을 읽었다고 주장했는가source: 그 주장을 누가 만들었는가evidence: 어느 page의 어떤 excerpt를 근거로 냈는가verified: deterministic guard가 그 연결을 입증했는가unverified: 소비자가 빠르게 찾아야 할 실패 경로는 무엇인가
값을 삭제하면 첫 번째 정보까지 잃습니다.
verified를 생략하면 네 번째 정보가 사라집니다.
둘을 함께 남겨야 caller가 자기 상황에 맞는 결정을 할 수 있습니다.
예를 들어 검수 화면은 실패한 값을 다른 색으로 표시하고 사람이 원문과 비교하게 할 수 있습니다.
자동 정산 경로는 verified: false인 값을 계산에서 제외할 수 있습니다.
디버깅 로그는 모델이 무엇을 주장했는지와 왜 신뢰되지 않았는지를 함께 보여줄 수 있습니다.
API가 미리 값을 버리면 이 선택지는 모두 사라집니다.
unverified는 값의 대체물이 아니라 색인입니다
왜 evidence를 가진 field와 item에 verified가 있는데 별도의 unverified 배열까지 둘까요?
중첩된 응답에서 실패를 찾기 위해 evidence를 가진 field와 item을 다시 순회하지 않게 하기 위해서입니다.
다음처럼 path만 모아두면 검수 대상 목록을 바로 만들 수 있습니다.
{
"unverified": ["fields.purchaseDate", "fields.reference", "items[1]"]
}
다만 이 배열만 보고 값을 복구할 수는 없습니다.
실제 value, source, excerpt는 각 evidence-bearing field나 item에 그대로 있어야 합니다.
verified는 local state이고, unverified는 그 state를 탐색하기 위한 index입니다.
같은 사실을 중복 저장하는 것처럼 보여도 두 소비 방식이 다릅니다.
parser라고 자동 통과시키지 않았습니다
deterministic parser는 model보다 예측 가능합니다.
그렇다고 항상 맞는 것은 아닙니다.
현재 contract는 parser가 만든 field도 같은 resolve()를 통과시킵니다.
source가 parser인지 model인지에 따라 verified: true를 선물하지 않습니다.
이 구분은 중요합니다.
source는 provenance입니다.
verified는 evidence와 value의 연결을 guard가 확인했는지 나타냅니다.
둘을 같은 축으로 취급하면 “deterministic하게 틀린 값”이 다시 사실처럼 보입니다.
반대로 guard가 통과했다고 의미 해석까지 완벽하다는 뜻도 아닙니다.
excerptContainsAmount()는 excerpt 안에 같은 amount가 있는지 확인합니다.
그 숫자가 total인지 cashier ID인지까지 결정하지는 않습니다.
그 역할은 parser baseline, corpus measurement, 그리고 별도의 disagreement report가 담당합니다.
검증 하나에 모든 신뢰 문제를 떠넘기지 않은 이유입니다.
disagreements도 별도 축으로 남겼습니다
model이 12.99를 claim했고 excerpt에도 12.99가 실제로 있다면 amount guard는 통과할 수 있습니다.
하지만 parser가 그 cited line을 다시 읽어 다른 값을 선택할 수도 있습니다.
이 경우 verified와 disagreements는 서로 다른 질문에 답합니다.
verified: claim한 값이 cited evidence 안에 존재하는가?disagreements: parser가 같은 evidence를 다시 읽었을 때 같은 값에 도달했는가?
검증 통과와 해석 합의는 같은 상태가 아닙니다.
그래서 disagreement는 guard 결과와 무관하게 별도 배열에 기록됩니다.
하나의 confidence 숫자로 합쳤다면 어떤 종류의 의심인지 설명하기 어려웠을 것입니다.
구조적으로 invalid한 reply는 또 다른 실패입니다
모델 응답이 schema 자체를 통과하지 못하는 경우도 있습니다.
evidence가 빠졌거나 type이 잘못됐거나 허용하지 않은 field가 들어온 경우입니다.
이것은 value 하나가 suspect한 상황보다 위쪽의 실패입니다.
현재 pipeline은 그런 model reply를 model-derived fact가 없는 응답으로 취급합니다.
그리고 다음처럼 rejection 상태를 별도로 반환합니다.
type ModelReplyStatus = { accepted: true } | { accepted: false; reason: string };
구조적으로 invalid한 reply의 value는 contract 안으로 들어오지 않았으므로 unverified에도 추가하지 않습니다.
items: []만 반환하면 “모델이 아무것도 찾지 못함”과 “모델 응답이 버려짐”이 같아 보입니다.
modelReply.accepted는 그 둘을 구분합니다.
실패를 하나의 boolean으로 압축하지 않고 발생한 boundary에 남긴 셈입니다.
fail closed는 빈 문자열에서 시작합니다
evidence guard에서 가장 작은 공격 입력은 거대한 prompt injection이 아니었습니다.
빈 문자열이었습니다.
JavaScript에서 모든 문자열은 ""를 포함합니다.
따라서 단순 includes() 검사는 빈 excerpt를 evidence로 승인할 수 있습니다.
text value도 같습니다.
빈 merchant를 찾으면 어떤 line에서도 substring match가 됩니다.
그래서 verifyEvidence()와 excerptContainsText()는 empty 또는 whitespace-only input에서 false를 반환합니다.
정규화도 두 guard가 공유합니다.
NFKC와 whitespace collapse 기준이 다르면 한쪽은 같은 excerpt라고 보고 다른 쪽은 다른 excerpt라고 볼 수 있기 때문입니다.
“애매하면 통과”가 아니라 “입증하지 못하면 표시”가 이 contract의 기본값입니다.
테스트는 두 guard를 각각 망가뜨려 봐야 했습니다
이번 글을 쓰며 focused test를 다시 실행했습니다.
node --test packages/contract/test/guards.test.ts
15 tests passed
extract pipeline의 fabricated string/date case도 따로 실행했습니다.
node --test --test-name-pattern='a fabricated string or date is marked unverified' apps/web/test/extract.test.ts
1 test passed
그 test의 synthetic page는 다음과 같습니다.
BLUE BOTTLE
SANDWICH 12.99
TOTAL 12.99
model은 purchaseDate: 1999-01-01에 BLUE BOTTLE을 인용하고, 존재하지 않는 reference에 TOTAL 12.99를 인용합니다.
schema는 이 reply를 받아들입니다.
그러나 두 field는 모두 verified: false가 되고, value는 응답에 남으며, 두 path가 unverified에 기록됩니다.
통과 결과만으로는 test가 올바른 실패를 읽는지 알 수 없습니다.
그래서 ad-hoc probe의 기대값을 먼저 틀리게 두었습니다.
expected: a real excerpt can verify 13,000
actual: false
그 기대가 실패한 뒤 실제 조건을 확인했습니다.
verifyEvidence("합계 14,800", page) === true;
excerptContainsAmount("합계 14,800", 13000) === false;
첫 guard만 보면 진짜 evidence입니다.
두 번째 guard까지 봐야 가짜 value라는 사실이 드러납니다.
값 보존은 신뢰 완화가 아니라 책임 분리입니다
verified: false를 붙인 값을 남기면 위험해 보일 수 있습니다.
그 위험은 사실입니다.
consumer가 flag를 무시하면 suspect value를 사용할 수 있습니다.
그렇다고 API가 값을 조용히 삭제하는 것이 더 정직한 것은 아닙니다.
삭제는 “아무것도 추출되지 않았다”와 “추출했지만 검증하지 못했다”를 같은 상태로 만듭니다.
현재 contract는 그 둘을 구분합니다.
parser나 model은 무엇을 읽었는지 말합니다.
guard는 그 주장이 evidence와 연결되는지 판정합니다.
response는 값과 판정을 함께 전달합니다.
caller는 어느 상태까지 자동 처리할지 결정합니다.
영수증처럼 사람이 원문을 다시 볼 수 있는 시스템에서, 저는 이 분리가 더 유용했습니다.
사라진 값은 검수할 수 없습니다.
남은 값은 적어도 왜 의심받는지 추적할 수 있습니다.
그리고 그 차이를 표현하는 가장 작은 계약이 verified: false였습니다.
다음 글도 받아보세요.
글을 끝까지 읽으셨다면, 다음 글은 받은편지함이나 RSS 리더에서 만나보세요.