Files
homeclaw/tests/numeric-grounding.test.ts
T
kimandClaude Opus 5 db6a8634c3 fix: 가드가 맞는 답을 지우던 경로 — 무관한 검색 결과에 대고 판정하던 문제
2026-09-04 메인챗 세션(0c367bd7) 감사. 사용자가 "옵션 프리미엄이 실물의 몇 %냐"를
다섯 턴에 걸쳐 물었고, 검색 6회에 산출물 0건으로 끝났다. 로그를 보니 원인이 모델이
아니라 우리 가드였다:

  TOOL: web_search("typical option premium as percentage of underlying asset price")
  TOOL OK: [1] TYPICAL Definition & Meaning - Merriam-Webster
  NUMERIC-GROUNDING POST-CHECK (1/1): 3 figure(s) not found in tool output [10%, 1%, 2%]

모델은 매 턴 답을 냈다(10%/1%/2%, 다음 턴엔 3~10%/0.1~1%). 검색엔진이 "typical"의
사전 뜻풀이를 물어왔고, 그 뭉치에 숫자가 없다는 이유로 답이 날조 판정을 받아 지워졌다.
이 가드의 전제는 "검색이 그 주제를 다뤘는데 숫자가 없다 → 지어냈다"인데, 앞부분을
한 번도 확인한 적이 없었다. 무관한 말뭉치에서의 부재는 날조의 증거가 아니다.

* 관련성 관문(sourceCoversQuery): 검색어의 내용어 중 결과에 등장하는 비율이 0.34
  미만이면 판정을 포기한다. 실측 — 사전 뭉치 0.14 / 정상 결과 0.86. 수치·날짜·부재주장
  세 갈래가 같은 전제 위에 있어 한꺼번에 게이트했다. 검색어를 못 넘기는 호출부는 기존
  동작 유지.

* SEARCH-ABANDONMENT도 같은 결함이 있었다(감사 중 발견). countSubstantiveResults는
  스니펫 길이만 봐서 사전 페이지 4건을 전부 "알맹이 있음"으로 세고, 모델에게 "그걸로
  답하라"고 민다 — 답을 지우는 대신 틀린 답을 강요하는, 같은 전제의 반대 방향 피해다.
  같은 관문을 공유시켜 기준이 갈라지지 않게 했다.

* 그 가드는 애초에 죽어 있었다. NOTHING_FOUND가 "정보가 없다"류만 담고 있어, gemma4가
  실제로 쓰는 "결과에 …이 포함되어 있지 않다"(주어가 결과)를 하나도 못 잡았다. 이
  세션의 포기 답변 6건 중 0건 탐지. 사과로 시작하면 판정 대상인 첫 문장이 "죄송합니다."가
  되는 두 번째 구멍도 함께 수정(한글 뒤 \b 미매칭 함정 주의).

* dead-end-fallback (신규): 두 번째로 빈손이면 검색을 끊고 "아는 것을 검증 못 했다고
  라벨 붙여 말하라"로 돌린다. 문제의 스레드에서 3턴 앞서 끊긴다. 재프롬프트가 숫자를
  요구하지 않아 NUMERIC-GROUNDING과 충돌하지 않는다.

* 제품·모델 선택 질문이 isFactualInfoRequest를 통째로 빠져나갔다("모델이 있을까",
  "고른다면", "신형/최신"). 그래서 소설-모델 3턴이 도구 0개로 굴러가 2026년 9월에
  "Gemma 2 27B"를 로컬 추천으로 냈다. 주제어가 앞 턴에만 있는 짧은 후속 질문용
  isFactualThreadFollowUp 추가.

* confirmsUserGuessWithoutGrounding: 직전 턴에 확인 못 했다고 해놓고 사용자 추측에
  "네, 맞습니다"로 동조하는 경로 차단(맥미니 M6 건). 4중 조건으로 좁혀 정당한 맞장구는
  건드리지 않는다.

* cms_hospital_compare 게이트가 "병원"에만 걸려 있어 사용자가 내내 쓴 "의료법인"으로는
  8턴 동안 스코프에 들지 않았다. 그 사이 공공 비중 답이 45~50%→39~40%→약 50%로 흔들렸다.

테스트 521개 통과(+65). 실사용 로그의 실제 발화·답변을 표본으로 고정했다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rfme1WVEPkpNwnf5oVNXXc
2026-09-04 17:51:12 +09:00

386 lines
19 KiB
TypeScript

/**
* numeric-grounding.test.ts
*
* The last guard in the chain, and the only one that asks whether a NUMBER came from the search
* rather than whether a search happened. Its failure mode is the worst in the codebase: an
* invented specification delivered as a measurement, with a real search in the transcript to
* make it look sourced.
*
* Both directions matter equally here and pull against each other:
* - miss a fabrication → the user acts on an invented figure
* - flag a good answer → a pointless retry, and eventually the guard gets disabled
* The balance comes from scoping: judge the figure that ANSWERS the question (first claim in the
* unit the question named), not every number in the reply. Two earlier thresholds failed live and
* both are pinned below — "every claim must miss" was shielded by the question's own echoed
* numbers, and then by a genuine comparison figure quoted alongside the invented one.
*/
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { checkNumericGrounding, extractNumericClaims, sourceCoversQuery } from '../src/gateway/chat/numeric-grounding';
describe('주장 추출 — (숫자, 단위) 쌍', () => {
test('단위가 붙은 수치만 뽑는다', () => {
const c = extractNumericClaims('VRAM은 32GB이고 대략 5개 정도 있습니다.');
assert.equal(c.length, 1);
assert.deepEqual(c[0].numbers, ['32']);
assert.equal(c[0].unit, 'GB');
});
test('범위는 양 끝을 모두 담는다', () => {
const c = extractNumericClaims('초당 약 2~5 토큰');
assert.deepEqual(c[0].numbers, ['2', '5']);
});
test('천단위 쉼표를 정규화한다', () => {
assert.deepEqual(extractNumericClaims('1,024GB')[0].numbers, ['1024']);
});
test('코드블록 안 숫자는 주장이 아니다', () => {
assert.equal(extractNumericClaims('```py\nSIZE_GB = 80\n```').length, 0);
});
});
describe('근거 판정 — 단위 근처에 있어야 인정', () => {
test('도구가 준 수치는 근거 있음', () => {
const r = checkNumericGrounding(
'내일 구미는 최저 23.0°C 최고 33.0°C, 강수확률은 20%입니다.',
['기상청 단기예보\n 기온: 23.0~33.0°C\n 강수확률: 20%'],
);
assert.equal(r.allUnsupported, false);
});
test('[2026-07-29] 출처에 없는 수치는 걸린다 — 실제 사고 케이스', () => {
// M40+Qwen 조합의 실측치는 어디에도 없는데 모델이 값을 냈다. 검색은 실제로 수행됐고
// 결과도 비어있지 않아서 앞선 세 가드가 모두 통과시킨 상황.
const r = checkNumericGrounding(
'초당 약 2~5 토큰 정도 나옵니다.',
['[1] Running Local LLMs on M4: 24GB RAM Performance Guide ... [2] Qwen 3.6 35B MoE 일반 가이드'],
);
assert.equal(r.allUnsupported, true);
assert.equal(r.unsupported[0].text, '2~5 토큰');
});
test('맨숫자 우연 일치로 통과시키지 않는다', () => {
// "2"와 "5"는 출처 어딘가에 파편으로 존재한다. 단위를 함께 보지 않으면 근거 있음이 된다.
const r = checkNumericGrounding('초당 2 토큰입니다.', ['버전 2.5 문서, 페이지 5, 항목 2개']);
assert.equal(r.allUnsupported, true);
});
});
describe('직접 답변 판정 — 진짜 인용이 날조를 가리지 못하게', () => {
test('[2026-07-29] 같은 단위의 진짜 인용이 옆에 있어도 날조를 잡는다', () => {
// 실제 2차 실패: "157.66 tok/s on RTX 3090"은 페이지에 진짜 있는 값이라 근거 있음으로
// 잡히고, 그 하나가 질문 대상(M40 조합)의 날조를 가려주고 있었다.
const r = checkNumericGrounding(
'총 48GB 구성에서 초당 약 2~5 토큰입니다. 참고로 RTX 3090은 157 tok/s.',
['UD-Q4_K_M ... runs at 157.66 tok/s on our own RTX 3090'],
'Tesla M40 24GB 2장으로 Qwen3.6 35B 돌리면 초당 몇 토큰?',
);
assert.equal(r.allUnsupported, true);
assert.equal(r.primaryClaim?.text, '2~5 토큰');
});
test('질문이 물은 수치를 제대로 인용하면 통과', () => {
const r = checkNumericGrounding(
'RTX 3090은 157.66 tok/s가 나옵니다.',
['runs at 157.66 tok/s on our own RTX 3090'],
'RTX 3090 초당 몇 토큰?',
);
assert.equal(r.allUnsupported, false);
});
test('질문 단위 밖의 수치는 판정 대상이 아니다', () => {
// "몇 토큰"을 물었으면 GB 수치는 맥락일 뿐이다.
const r = checkNumericGrounding(
'초당 5 토큰이며 VRAM은 99GB 필요합니다.',
['throughput: 5 토큰'],
'초당 몇 토큰?',
);
assert.equal(r.askedUnit, '토큰');
assert.equal(r.allUnsupported, false);
});
});
describe('보수적 임계값 — 오탐 방지가 우선', () => {
test('파생 수치는 허용한다 — 앞선 근거가 답을 지탱하면 통과', () => {
// 48GB는 24GB×2로 유도한 값이라 출처에 없다. 이런 계산을 막으면 안 된다.
const r = checkNumericGrounding('카드당 24GB이므로 2장이면 48GB입니다.', ['spec: 24GB per card']);
assert.equal(r.allUnsupported, false);
});
test('질문에 있던 숫자를 되뇌는 것은 근거로 치지 않는다', () => {
// 이 예외가 없으면 질문의 "24GB"가 유일한 근거로 잡혀 날조 답변이 통과한다(실제로 그랬음).
const r = checkNumericGrounding(
'Tesla M40 24GB 2장이면 초당 약 2~5 토큰입니다.',
['관련 없는 검색 결과'],
'Tesla M40 24GB 2장으로 돌리면 초당 몇 토큰?',
);
assert.equal(r.allUnsupported, true);
});
test('수치 주장이 없으면 발동하지 않는다', () => {
assert.equal(checkNumericGrounding('메모리 대역폭이 병목입니다.', ['아무 문서']).allUnsupported, false);
});
test('출처 자체가 비면 발동하지 않는다 — 빈검색결과 가드 담당', () => {
assert.equal(checkNumericGrounding('32GB입니다.', []).allUnsupported, false);
assert.equal(checkNumericGrounding('32GB입니다.', [' ']).allUnsupported, false);
});
// 2026-09-02: weather_openmeteo 표는 단위를 헤더 범례에만 쓰고("…precipitation_probability=%")
// 값은 열에 맨숫자로 넣는다. 근접 매칭이 "55"와 "%"를 멀다고 판단해 정확한 강수확률 답변마다
// NUMERIC-GROUNDING 재프롬프트(→ "죄송합니다…")가 걸렸다.
test('범례형 표 출처 — 헤더에 단위가 있으면 열 값이 오탐되지 않는다', () => {
const table = [
'단위: temperature_2m=°C, precipitation_probability=%, relative_humidity_2m=%',
'시각\ttemperature 2m\tprecipitation probability\trelative humidity 2m',
'2026-09-02 15:00\t27.8\t55\t78',
'2026-09-02 16:00\t27.9\t53\t78',
].join('\n');
assert.equal(
checkNumericGrounding('오후 3시 강수확률 55%, 습도 78%입니다.', [table], '구미 이번주 날씨').allUnsupported,
false,
);
});
test('범례형 표라도 표에 없는 수치는 여전히 잡는다', () => {
const table = '단위: precipitation_probability=%\n시각\tprob\n15:00\t55\n16:00\t53';
assert.equal(
checkNumericGrounding('강수확률이 무려 91%까지 오릅니다.', [table], '구미 날씨').allUnsupported,
true,
);
});
// 2026-09-02: 영어/한국어 출처가 "percent"/"퍼센트"로 쓴 걸 답변이 "%"로 옮기면 오탐이던 문제.
test('단위 철자 표기 차이 — 출처 "percent", 답변 "%"', () => {
assert.equal(
checkNumericGrounding(
'영리병원은 전체의 약 25%를 차지합니다.',
['For-profit hospitals make up roughly 25 percent of all US community hospitals.'],
'영리병원 비율은?',
).allUnsupported,
false,
);
assert.equal(
checkNumericGrounding('용량은 24GB입니다.', ['equipped with 24 gigabytes of memory'], '이 카드 VRAM?').allUnsupported,
false,
);
});
});
describe('날짜 검증 — 출처마다 표기가 다르므로 형식 관용이 필수', () => {
const q = 'Tesla M40은 정확히 몇 년 몇 월에 출시됐어?';
const a = 'NVIDIA Tesla M40은 2015년 11월 10일에 출시되었습니다.';
test('영문·ISO·한글 어떤 표기든 근거로 인정한다', () => {
// 한국어 답변 + 영문 출처는 정상 조합이다. 이걸 못 맞추면 정답이 오탐으로 걸린다.
for (const src of [
'NVIDIA Tesla M40 ... Release Date: Nov 10th, 2015 ... Maxwell',
'Launched November 2015 alongside the M4',
'release_date: 2015-11-10, arch: Maxwell',
'테슬라 M40은 2015년 11월 출시',
]) {
assert.equal(checkNumericGrounding(a, [src], q).dateUnsupported, false, `표기 인식 실패: ${src}`);
}
});
test('연도만 맞고 월이 다르면 걸린다', () => {
const r = checkNumericGrounding(a, ['Released in 2015. In March 2015 NVIDIA announced ...'], q);
assert.equal(r.dateUnsupported, true);
assert.equal(r.primaryDate?.year, '2015');
});
test('출처가 무관하면 걸린다', () => {
assert.equal(
checkNumericGrounding(a, ['Nvidia Corporation is an American company headquartered in Santa Clara'], q).dateUnsupported,
true,
);
});
test('날짜를 묻지 않은 질문에서는 답변의 연월을 판정하지 않는다', () => {
// 날씨 답변에 "2026년 7월 기준" 같은 표현이 섞여도 검증 대상이 아니다.
const r = checkNumericGrounding('내일 구미는 23~33°C입니다. 2026년 7월 기준 평년보다 높습니다.', ['기온: 23.0~33.0°C'], '내일 구미 날씨');
assert.equal(r.allUnsupported, false);
assert.equal(r.primaryDate, null);
});
});
describe('순위는 의도적으로 검증하지 않는다', () => {
test('순위표는 "40 구미시" 처럼 단위 없이 쓰므로 정답이 오탐되면 안 된다', () => {
// 이 케이스가 순위 검증을 넣지 않기로 한 근거다. "위" 인접을 요구하면 정답이 걸리고,
// 맨숫자 "40"만 찾으면 어떤 문서에나 있어 검증이 무의미해진다.
const r = checkNumericGrounding(
'구미시의 인구는 402,726명으로 전국 기초자치단체 중 40위입니다.',
['순위 지역 인구(명) ... 39 대구광역시 북구 407,548 40 경상북도 구미시 402,726'],
'구미시 인구는 전국 시 단위에서 몇 위야?',
);
assert.equal(r.allUnsupported, false);
});
});
describe('"아직 출시 안 됐다" 주장 — 숫자가 없어도 검증한다', () => {
// 2026-08-11 실제 사고. RTX 4080 vs 5060 비교 요청에 모델이 검색은 했고(→AUTO-RECOVER 통과),
// 결과도 비어있지 않았고(→EMPTY-GROUNDING 통과), 수치를 하나도 안 썼다(→기존 숫자검증에 걸릴
// 주장 0건). 그러면서 "RTX 5060의 미출시 상태"를 답을 못 주는 이유로 단정했다 — 실제로는
// 사용자 지서버에 꽂혀 있는 카드다. 모델 학습 컷오프(2025-01) 4개월 뒤 출시라 기억에 없을 뿐인데,
// "내 기억에 없다"를 "세상에 없다"로 바꿔 말한 것이다.
const REAL_ANSWER = `현재 검색 결과만으로는 RTX 4080과 RTX 5060의 구체적인 성능 차이를 확정적으로 말씀드리기 어렵습니다.
- 비교 데이터 부재: 기술적 비교를 시도한 페이지는 현재 접속이 불가능한 상태입니다.
- RTX 5060의 미출시 상태: NVIDIA 공식 페이지에서는 RTX 50 시리즈에 대한 일반적인 소개만 있을 뿐입니다.`;
test('출처에 근거가 없는 미출시 단정을 잡는다', () => {
const r = checkNumericGrounding(
REAL_ANSWER,
['GeForce RTX 50 시리즈 - Blackwell 아키텍처 기반 그래픽카드 라인업 소개'],
'5060 보다 얼마나 빠르지 4080?',
);
assert.equal(r.nonexistenceUnsupported, true);
assert.equal(r.allUnsupported, true);
});
test('출처가 실제로 미출시라고 하면 발동하지 않는다', () => {
const r = checkNumericGrounding(
'RTX 6090은 아직 출시되지 않았습니다.',
['RTX 6090 is not yet released; NVIDIA has announced an expected launch in late 2027.'],
'RTX 6090 나왔어?',
);
assert.equal(r.nonexistenceUnsupported, false);
assert.equal(r.allUnsupported, false);
});
test('사용자가 질문에 적은 전제를 되풀이한 것은 모델의 주장이 아니다', () => {
const r = checkNumericGrounding(
'말씀하신 대로 아직 출시되지 않은 제품이라 벤치마크가 없습니다.',
['관련 정보를 찾을 수 없습니다'],
'그거 아직 출시되지 않았지?',
);
assert.equal(r.nonexistenceClaim, null);
assert.equal(r.nonexistenceUnsupported, false);
});
test('출처가 아예 없으면 판정하지 않는다 (EMPTY-GROUNDING이 담당)', () => {
const r = checkNumericGrounding('아직 출시되지 않았습니다.', [], '나왔어?');
assert.equal(r.nonexistenceUnsupported, false);
});
test('영어 표현도 잡는다', () => {
const r = checkNumericGrounding(
'The RTX 5060 has not been released yet, so no benchmarks exist.',
['NVIDIA GeForce graphics cards overview page'],
'how much faster is the 4080 than the 5060?',
);
assert.equal(r.nonexistenceUnsupported, true);
});
test('부정문이라고 다 잡지는 않는다 — 출시/존재 동사만', () => {
const r = checkNumericGrounding(
'이 기능은 무료 요금제에서는 지원되지 않습니다.',
['Free plan feature comparison table'],
'무료로 쓸 수 있어?',
);
assert.equal(r.nonexistenceClaim, null);
assert.equal(r.allUnsupported, false);
});
});
// 2026-09-02 로그 감사: NUMERIC-GROUNDING 발동 20건 중 7건이 하나의 연금 감액률 스레드였다.
// 한국 법령·행정규칙은 퍼센트를 "%"가 아니라 "100분의 N"으로 쓴다 — 답변의 "50%"가 출처의
// "100분의 50"과 대조되지 못해 매번 오탐이었다.
describe('법령 표기 — 100분의 N = N%', () => {
test('출처가 "100분의 50"이면 답변 "50%"는 근거 있음', () => {
assert.equal(
checkNumericGrounding('퇴직급여의 50%가 감액됩니다.', ['제65조(급여의 제한) 급여의 100분의 50을 감액한다.'], '감액 비율은?').allUnsupported,
false,
);
});
test('출처에 없는 비율은 여전히 잡는다', () => {
assert.equal(
checkNumericGrounding('80%가 감액됩니다.', ['급여의 100분의 50을 감액한다.'], '감액 비율은?').allUnsupported,
true,
);
});
});
/**
* 무관한 검색 결과에는 판정하지 않는다 — 2026-09-04 세션 0c367bd7 사고.
*
* 이 가드가 다섯 턴 연속으로 사용자가 물은 답을 지웠다. 로그 그대로:
* TOOL: web_search("typical option premium as percentage of underlying asset price")
* TOOL OK: [1] TYPICAL Definition & Meaning - Merriam-Webster
* NUMERIC-GROUNDING POST-CHECK (1/1): 3 figure(s) not found in tool output [10%, 1%, 2%]
* 다음 턴엔 [3~10%, 0.1~1%, 10%, 1%]가 같은 이유로 지워졌다. 검색엔진이 "typical"의 사전
* 뜻풀이를 물어왔을 뿐인데, 그 뭉치에 숫자가 없다는 이유로 답이 날조 판정을 받은 것이다.
*/
describe('sourceCoversQuery — 검색 결과가 그 주제를 다뤘는가', () => {
const QUERY = 'typical option premium as percentage of underlying asset price';
const DICTIONARY_JUNK = [
'[1] TYPICAL Definition & Meaning - Merriam-Webster',
' https://www.merriam-webster.com/dictionary/typical',
' The meaning of TYPICAL is combining or exhibiting the essential characteristics of a group.',
'[2] TYPICAL | English meaning - Cambridge Dictionary',
' https://dictionary.cambridge.org/dictionary/english/typical',
' TYPICAL definition: showing all the characteristics you would usually expect from a group.',
].join('\n');
const ON_TOPIC = [
'[1] Glossary - Common Fidelity Terms',
' https://www.fidelity.com/glossary',
' Option premium is the price paid for an option contract. The premium on an underlying',
' asset reflects a percentage of price, volatility and time to expiry.',
].join('\n');
test('사전 뜻풀이 뭉치는 주제를 안 다룬 것으로 본다', () => {
const r = sourceCoversQuery(QUERY, DICTIONARY_JUNK);
assert.equal(r.onTopic, false);
assert.ok(r.coverage < 0.34, `coverage=${r.coverage}`);
});
test('실제로 옵션 프리미엄을 다룬 결과는 통과시킨다', () => {
const r = sourceCoversQuery(QUERY, ON_TOPIC);
assert.equal(r.onTopic, true);
assert.ok(r.coverage > 0.6, `coverage=${r.coverage}`);
});
test('검색어가 너무 짧으면 판정하지 않는다 — 근거가 부족하다', () => {
assert.equal(sourceCoversQuery('맥미니', DICTIONARY_JUNK).onTopic, true);
});
test('소스가 비어 있으면 판정하지 않는다 — 빈 결과는 다른 가드의 몫', () => {
assert.equal(sourceCoversQuery(QUERY, '').onTopic, true);
});
});
describe('checkNumericGrounding — 무관한 소스에는 날조 판정을 내리지 않는다', () => {
const QUERY = 'typical option premium as percentage of underlying asset price';
const DICT = '[1] TYPICAL Definition & Meaning - Merriam-Webster\n The meaning of TYPICAL is exhibiting the essential characteristics of a group.';
const ON_TOPIC = '[1] Fidelity Glossary\n Option premium is the price of an option contract; the premium on an underlying asset is a percentage of price and volatility.';
// 실제로 지워진 답변과 같은 모양.
const ANSWER = '옵션 프리미엄은 보통 기초자산 가격의 3~10% 수준이며, 외가격이면 0.1~1%까지 내려갑니다.';
test('사전 결과에 대고는 판정을 포기한다 — 이 사고의 직접 수정', () => {
const r = checkNumericGrounding(ANSWER, [DICT], '대충 얼마냔거지', [QUERY]);
assert.equal(r.sourceOnTopic, false);
assert.equal(r.allUnsupported, false);
});
test('주제를 다룬 결과에는 그대로 판정한다 — 가드를 무력화하면 안 된다', () => {
const r = checkNumericGrounding(ANSWER, [ON_TOPIC], '대충 얼마냔거지', [QUERY]);
assert.equal(r.sourceOnTopic, true);
assert.equal(r.allUnsupported, true);
});
test('한 번이라도 주제를 제대로 검색했으면 판정한다', () => {
const r = checkNumericGrounding(ANSWER, [DICT, ON_TOPIC], '대충 얼마냔거지',
['some unrelated words here', QUERY]);
assert.equal(r.sourceOnTopic, true);
});
test('검색어를 안 넘기면 예전 동작 그대로', () => {
const r = checkNumericGrounding(ANSWER, [DICT], '대충 얼마냔거지');
assert.equal(r.sourceOnTopic, true);
assert.equal(r.allUnsupported, true);
});
});