한 줄 요약

같은 입력이면 항상 같은 점수를 내는 결정론 채점기 두 개(키워드 커버리지 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, 테스트 통과, “왜냐하면”, 파일 경로, “검증” 같은 흔적이 있어야 점수를 채운다. 키워드만 나열하고 근거가 없으면 절반밖에 못 받는다.

핵심은 세 가지다.

  1. 두 채점관은 살아 있는 LLM이 아니라 규칙 코드다. 그래서 같은 답안을 다시 채점해도 점수가 한 글자도 안 바뀐다(재현 가능). LLM 채점은 같은 답안에도 점수가 흔들리지만, 이건 안 흔들린다.
  2. 입력이 부실하면 점수를 후하게 주지 않고 0점으로 본다(fail-closed). rubric이 비었거나 답안이 비었으면 0점이다. “잘 모르겠으니 일단 점수 줄게”가 없다.
  3. 두 채점관이 갈리면(25점 이상 차이) 평균으로 뭉개지 않는다. “A는 50, B는 100” 그대로 raw.json에 남긴다. 평균 75로 덮으면 “한 명은 이걸 형편없다고 봤다”는 정보가 사라지기 때문이다. 이 불일치 보존이 단일 채점관의 자기편향을 드러내는 장치다.

비유하자면, 두 심사위원의 점수표를 그대로 보관하는 올림픽 채점과 같다. 평균만 발표하면 “한 심사위원이 편파 판정했다”는 신호를 영영 못 잡는다.

핵심 정리

구성요소역할핵심 규칙
deterministic_judge (judge B)키워드 커버리지 채점hits/valid_keywords × 100, 소수 6자리 고정
rubric_judge_a (judge A)근거 마커 가중 채점coverage 02.5점 + evidence 02.5점 → 0..100 환산
disagree_runs한 출력에 A·B 동시 적용delta ≥ 25.0 이면 disagree, 평균 안 냄
write_rawraw JSON 직렬화sort_keys=True, score를 raw와 함께 기록

실제 예시

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가 점수 주장과 원자료 정합을 검사할 수 있게 한다.

셀프체크

  1. 어떤 출력이 “키워드는 전부 맞췄는데 judge A에서 절반밖에 못 받는” 상황을 만들까? (힌트: _QUALITY_MARKER_RE에 걸리는 근거 흔적이 하나도 없을 때)
  2. 두 심판 점수를 왜 평균 내지 않고 양쪽 다 남기나? 평균으로 덮으면 어떤 정보가 사라지나?
  3. reproduce.sh가 sentinel 87을 만나면 exit 70으로 멈추는 이유는? 이것이 fail-closed 설계와 어떻게 연결되나?

연결

FL_개요 · _분석축_루브릭