한 줄 요약
같은 입력이면 항상 같은 점수를 내는 결정론 채점기 두 개(키워드 커버리지 judge B, 근거 마커 가중 judge A)로 픽스처를 채점하고, 두 점수를 평균으로 합치지 않은 채 raw JSON에 통째로 남긴다. 라이브 모델 없이도 하네스 효과를 정직하게 재현 측정하는 법을 배우려고 본다.
그림
flowchart TD T["BenchTask<br/>(id, prompt, rubric)"] --> N["_normalize<br/>소문자화 + 공백 단일화"] O["model output<br/>(문자열)"] --> N N --> B["deterministic_judge<br/>judge B: 키워드 커버리지 0..100"] N --> A["rubric_judge_a<br/>judge A: 커버리지 절반 + 근거마커 절반"] B --> D{"delta >= 25.0 ?"} A --> D D -->|"yes"| DIS["disagree=true<br/>양쪽 점수 모두 보존"] D -->|"no"| AGR["disagree=false<br/>그래도 양쪽 점수 모두 보존"] DIS --> R["BenchRun.raw<br/>(score, other_score, delta)"] AGR --> R R --> W["write_raw<br/>sort_keys=True 결정론 직렬화"] W --> J["bench/raw.json /<br/>bench/fixtures_raw.json"] J --> S["reproduce.sh SENTINEL_GUARD<br/>점수 주장 vs raw.json 정합 검사"]
flowchart LR OUT["키워드는 다 채웠지만<br/>근거/검증 흔적 없음"] --> JB["judge B 높음<br/>(coverage 100)"] OUT --> JA["judge A 낮음<br/>(markers 0)"] JB --> SPLIT["불일치 = 단일 심판<br/>자기편향 노출"] JA --> SPLIT
쉽게 풀기
시험 답안 두 명이 채점한다고 생각하면 된다.
- 채점관 B(judge B)는 “정답 키워드가 답안에 몇 개나 들어 있나”만 센다. rubric에 키워드 6개가 있고 답안에 4개가 보이면 4/6 = 66.67점. 단순하고 빠르다.
- 채점관 A(judge A)는 더 까다롭다. 절반은 똑같이 키워드 충족으로 주지만, 나머지 절반은 “실제로 근거를 댔나”를 본다. exit code, 테스트 통과, “왜냐하면”, 파일 경로, “검증” 같은 흔적이 있어야 점수를 채운다. 키워드만 나열하고 근거가 없으면 절반밖에 못 받는다.
핵심은 세 가지다.
- 두 채점관은 살아 있는 LLM이 아니라 규칙 코드다. 그래서 같은 답안을 다시 채점해도 점수가 한 글자도 안 바뀐다(재현 가능). LLM 채점은 같은 답안에도 점수가 흔들리지만, 이건 안 흔들린다.
- 입력이 부실하면 점수를 후하게 주지 않고 0점으로 본다(fail-closed). rubric이 비었거나 답안이 비었으면 0점이다. “잘 모르겠으니 일단 점수 줄게”가 없다.
- 두 채점관이 갈리면(25점 이상 차이) 평균으로 뭉개지 않는다. “A는 50, B는 100” 그대로 raw.json에 남긴다. 평균 75로 덮으면 “한 명은 이걸 형편없다고 봤다”는 정보가 사라지기 때문이다. 이 불일치 보존이 단일 채점관의 자기편향을 드러내는 장치다.
비유하자면, 두 심사위원의 점수표를 그대로 보관하는 올림픽 채점과 같다. 평균만 발표하면 “한 심사위원이 편파 판정했다”는 신호를 영영 못 잡는다.
핵심 정리
| 구성요소 | 역할 | 핵심 규칙 |
|---|---|---|
deterministic_judge (judge B) | 키워드 커버리지 채점 | hits/valid_keywords × 100, 소수 6자리 고정 |
rubric_judge_a (judge A) | 근거 마커 가중 채점 | coverage 0 |
disagree_runs | 한 출력에 A·B 동시 적용 | delta ≥ 25.0 이면 disagree, 평균 안 냄 |
write_raw | raw JSON 직렬화 | sort_keys=True, score를 raw와 함께 기록 |
결정론을 만드는 장치들 (코드 근거)
_normalize:re.sub(r"\s+", " ", s.lower()).strip()— 소문자화 + 연속 공백 단일화. 한국어는 lower 영향 없음.round(coverage, 6): 부동소수 잡음 제거용 소수 6자리 고정 → 직렬화 안정성.write_raw의json.dump(..., ensure_ascii=False, indent=2, sort_keys=True): 키 순서까지 고정해 diff 가 흔들리지 않게 함.DISAGREE_THRESHOLD = 25.0,SENTINEL_GUARD = True,JUDGE_A_NAME = "A_rubric_quality_0_5",JUDGE_B_NAME = "B_rule_keyword_coverage"가 모듈 상수로 박혀 있어 입력만 같으면 출력이 고정된다.
fail-closed 분기 (judge B / judge A 공통)
다음 중 하나라도 해당하면 즉시
0.0반환:
output이 문자열이 아님 (isinstance체크)task.rubric이 비어 있음- 정규화한 출력(
haystack)이 빈 문자열- 빈/공백 키워드를 거른
valid_keywords가 비어 있음 즉 “채점 근거가 없으면 점수를 주지 않는다”가 코드 레벨 불변식이다.
judge A 가 인정하는 근거 마커 (
_QUALITY_MARKER_RE)
exit code,exit\d,test ... pass/fail,grep,\d+ 개/matches, 파일경로(/x/y.ext),render,스크린샷,because,왜냐하면,therefore,따라서,step\d,단계,verified,검증,reproduc,재현. 중복도 끝까지 센다. rubric 항목 수만큼 마커가 있으면 evidence 만점(2.5)으로 포화.
실제 예시
judge B와 judge A의 채점 본체(라이브 모델 호출이 전혀 없음):
# /home/seunghyeong/harness-fablelayer/fablelayer/benchmark.py
def deterministic_judge(output: str, task: BenchTask) -> float:
if not isinstance(output, str):
return 0.0
rubric = task.rubric
if not rubric:
return 0.0
haystack = _normalize(output)
if not haystack:
return 0.0
valid_keywords = tuple(kw for kw in rubric if _normalize(kw))
if not valid_keywords:
return 0.0
hits = sum(1 for kw in valid_keywords if _keyword_hit(haystack, kw))
coverage = (hits / len(valid_keywords)) * 100.0
return round(coverage, 6) # 소수 6자리 고정 = 결정론 직렬화 안정성judge A는 같은 coverage를 절반만 쓰고, 나머지 절반은 근거 마커 밀도로 채운다:
# /home/seunghyeong/harness-fablelayer/fablelayer/benchmark.py
coverage_ratio = hits / len(valid_keywords)
coverage_points = coverage_ratio * 2.5 # 0~2.5점: rubric 충족
markers = _quality_markers(haystack)
evidence_ratio = min(markers / len(valid_keywords), 1.0)
evidence_points = evidence_ratio * 2.5 # 0~2.5점: 근거 밀도
score_0_5 = coverage_points + evidence_points # 0..5
score_100 = (score_0_5 / 5.0) * 100.0
return round(score_100, 6)불일치가 발생하는 실데이터(키워드는 다 채웠으나 markers 0):
// /home/seunghyeong/harness-fablelayer/bench/fixtures_raw.json (disagree-001, A run)
{
"delta": 50.0,
"disagree": true,
"disagree_threshold": 25.0,
"judge": "A_rubric_quality_0_5",
"matched_count": 6,
"other_judge": "B_rule_keyword_coverage",
"other_score": 100.0,
"quality_markers": 0,
"rubric_count": 6,
"score": 50.0
}재현 스크립트는 모델이 배선되지 않으면 합성 수치를 만들지 않고 멈춘다:
# /home/seunghyeong/harness-fablelayer/bench/reproduce.sh
run_model() {
return 87 # 87 = sentinel: model call not wired
}
# do_run() 안:
# rc=$?; if [ "$rc" -eq 87 ]; then
# echo "ABORT: run_model() is still the stub (sentinel 87)." >&2
# exit 70 # 가짜 점수 안 씀, raw.json 손 안 댐 (fail-closed)요약 & 셀프체크
3줄 요약
- judge B(키워드 커버리지)와 judge A(커버리지 절반 + 근거 마커 절반)는 규칙 코드라 같은 입력이면 항상 같은 점수가 나온다(재현 가능, 라이브 모델 불요).
- 입력이 부실하면 0.0으로 보는 fail-closed, 두 심판이 25점 이상 갈리면 평균으로 덮지 않고 양쪽 점수를 raw JSON에 그대로 남긴다.
- write_raw는 sort_keys=True로 결정론 직렬화하고 점수를 raw와 함께 기록해 reproduce.sh의 SENTINEL_GUARD가 점수 주장과 원자료 정합을 검사할 수 있게 한다.
셀프체크
- 어떤 출력이 “키워드는 전부 맞췄는데 judge A에서 절반밖에 못 받는” 상황을 만들까? (힌트:
_QUALITY_MARKER_RE에 걸리는 근거 흔적이 하나도 없을 때) - 두 심판 점수를 왜 평균 내지 않고 양쪽 다 남기나? 평균으로 덮으면 어떤 정보가 사라지나?
reproduce.sh가 sentinel 87을 만나면 exit 70으로 멈추는 이유는? 이것이 fail-closed 설계와 어떻게 연결되나?