Files
homeclaw/scripts/guard-firing-audit.py
kimandClaude Opus 5 83e9bd54f9 chore: 가드 발동률 실측 스크립트 — 회전에 견디는 누적 카운트 방식
첫 버전은 "몇 번째 줄 이후"로 기준선을 잡았는데, 로그 회전 설정을 확인해 보니 그 방식이
하루도 못 간다: logrotate가 크기 1M 기준 + `copytruncate`라, 회전 시 내용이 .1로 복사되고
원본은 0바이트로 잘린다. 패치 당일 gateway.log가 이미 1.05M이라 그날 밤 바로 잘릴 참이었다.

대신 gateway.log* 전 파일(.gz 포함)을 합쳐 패턴별 누적 횟수를 세고 기준선 값을 뺀다. 회전해도
내용은 .1/.2.gz로 옮겨갈 뿐 사라지지 않으므로 이 방식은 회전에 견딘다. rotate 14라 파일이
실제로 없어지려면 현재 증가 속도로 반년 이상 걸리고, 그때는 차이가 음수로 나와 경고한다.

패치 직전 기준(누적 1,011턴, 턴당): NUMERIC 재프롬프트 0.051 / AUTO-RECOVER 0.080 /
SEARCH-ABANDONMENT 0.010 / 중복 SKIP 0.035 / 합성 Answer: 0.009 / standing down 0(미존재).

2026-09-09 10:07 KST 1회 실행되도록 systemd user timer(guard-audit.timer) 등록. 이 박스는
매일 02시에 자므로 Persistent=true로 기상 후 따라잡게 했다. 결과는
.smallclaw/logs/audit/report-<날짜>.txt 로 남는다.

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

140 lines
6.6 KiB
Python
Executable File

#!/usr/bin/env python3
"""
가드 발동률 실측 — 2026-09-04 가드 패치(db6a863 / 10e54d3 / b2075c6) 이후 구간을 잰다.
왜 필요한가. 이번 패치들은 전부 실사용 로그를 표본으로 보정했지만, 보정이 맞았는지는
"패치 이후 로그"로만 확인할 수 있다([[feedback_calibrate_on_real_data]]). 특히 관련성
관문(sourceCoversQuery)은 양쪽으로 어긋날 수 있다:
· 너무 헐거우면 → NUMERIC-GROUNDING이 계속 맞는 답을 지운다(원래 사고의 재발)
· 너무 빡빡하면 → standing down이 과해져 진짜 날조가 그대로 통과한다
## 왜 줄 번호가 아니라 누적 카운트인가
로그는 크기 기준(1M)으로 회전하고 `copytruncate`를 쓴다 — 회전 시 내용이 .1로 복사되고
원본은 0바이트로 잘린다. 그래서 "몇 번째 줄 이후"라는 기준은 회전 한 번에 무효가 된다
(패치 당일 gateway.log가 이미 1.05M이라 그날 밤 바로 잘릴 참이었다).
대신 gateway.log* **전 파일(.gz 포함)** 을 합쳐 패턴별 누적 횟수를 세고, 기준선의 값을
뺀다. 회전해도 내용은 .1/.2.gz로 옮겨갈 뿐 사라지지 않으므로 이 방식은 회전에 견딘다.
`rotate 14`라 파일이 실제로 없어지려면 현재 증가 속도로 반년 이상 걸린다 — 그때는 차이가
음수로 나오고 스크립트가 경고한다.
사용:
python3 scripts/guard-firing-audit.py # 기준선 이후 증가분
python3 scripts/guard-firing-audit.py --total # 누적 전체(기준선 무시)
"""
import glob
import gzip
import json
import os
import re
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
LOG_GLOB = os.path.join(ROOT, '.smallclaw/logs/gateway.log*')
BASELINE = os.path.join(ROOT, '.smallclaw/logs/audit/guard-baseline.json')
# (키, 라벨, 정규식, 이번 패치와의 관계)
PATTERNS = [
('numeric_standdown', 'NUMERIC standing down', r'NUMERIC-GROUNDING: standing down', 'new'),
('numeric_reprompt', 'NUMERIC 재프롬프트', r'NUMERIC-GROUNDING POST-CHECK', 'should-drop'),
('search_abandonment', 'SEARCH-ABANDONMENT', r'SEARCH-ABANDONMENT POST-CHECK', 'should-rise'),
('dead_end_fallback', 'DEAD-END FALLBACK', r'DEAD-END FALLBACK POST-CHECK', 'new'),
('sycophantic', 'SYCOPHANTIC', r'SYCOPHANTIC-CONFIRMATION POST-CHECK', 'new'),
('auto_recover', 'AUTO-RECOVER', r'\[v2\] AUTO-RECOVER \(', 'baseline'),
('empty_grounding', 'EMPTY-GROUNDING', r'EMPTY-GROUNDING POST-CHECK', 'baseline'),
('intent_only', 'INTENT-ONLY', r'INTENT-ONLY POST-CHECK', 'baseline'),
('messaging', 'MESSAGING', r'MESSAGING POST-CHECK', 'baseline'),
('stale_version', 'STALE-VERSION QUERY', r'STALE-VERSION QUERY', 'baseline'),
('dup_skip', '중복 도구호출 SKIP', r'SKIP: duplicate tool call', 'baseline'),
('synth_answer', '합성 Answer: 줄', r'TOOL OK: Answer: The current', 'should-be-0'),
('user_turns', '사용자 턴', r'^\[v2\] USER \[', 'baseline'),
]
NOTE = {
'new': '이번에 추가',
'should-drop': '줄어야 정상',
'should-rise': '패치 전엔 이 어법을 못 잡았음',
'should-be-0': '0이어야 정상',
'baseline': '',
}
FIG_RE = re.compile(r'figure\(s\) not found in tool output \[([^\]]*)\]')
COV_RE = re.compile(r'cover only (\d+)% of the query')
def read_all_logs() -> str:
parts = []
for p in sorted(glob.glob(LOG_GLOB)):
try:
if p.endswith('.gz'):
parts.append(gzip.open(p, 'rt', errors='replace').read())
else:
parts.append(open(p, errors='replace').read())
except OSError as exc:
print(f' ⚠ {os.path.basename(p)} 읽기 실패: {exc}', file=sys.stderr)
if not parts:
sys.exit(f'로그를 찾지 못했습니다: {LOG_GLOB}')
return '\n'.join(parts)
def main() -> None:
text = read_all_logs()
total = {key: len(re.findall(rx, text, re.M)) for key, _, rx, _ in PATTERNS}
base = {}
if '--total' not in sys.argv and os.path.exists(BASELINE):
base = json.load(open(BASELINE)).get('counts', {})
if base:
delta = {k: total[k] - base.get(k, 0) for k in total}
if any(v < 0 for v in delta.values()):
print('⚠ 일부 카운트가 기준선보다 작습니다 — 오래된 로그 파일이 rotate 14를 넘겨')
print(' 삭제된 듯합니다. 아래 숫자는 신뢰하지 마시고 --total 로 보세요.\n')
counts, label = delta, '기준선(2026-09-04) 이후 증가분'
else:
counts, label = total, '누적 전체'
turns = counts.get('user_turns', 0)
print(f'구간: {label} — 사용자 턴 {turns}건\n')
if turns <= 0:
print('아직 이 구간에 대화가 거의 없습니다. 며칠 더 쓰고 다시 도세요.')
return
print(f'{"가드":<24}{"발동":>6}{"턴당":>9} 비고')
print('-' * 64)
for key, lbl, _, kind in PATTERNS:
if key == 'user_turns':
continue
c = counts[key]
print(f'{lbl:<24}{c:>6}{c / turns:>9.3f} {NOTE[kind]}')
# 지워진 수치가 실제로 무엇이었는지 — 사용자가 원한 답이 지워졌는지는 눈으로만 판정된다.
figs = FIG_RE.findall(text)
if figs:
print(f'\n지워진 수치 (누적 {len(figs)}건, 최근 12건):')
for f in figs[-12:]:
print(f' · {f}')
print(' ↑ 사용자가 원했던 답이 여기 섞여 있으면 관련성 관문이 아직 헐겁다.')
covs = sorted(int(m) for m in COV_RE.findall(text))
if covs:
print(f'\nstanding down 커버리지: {covs}')
print(f' 중앙값 {covs[len(covs) // 2]}% (임계값 34%)')
near = [c for c in covs if 25 <= c <= 33]
if near:
print(f' ⚠ 임계값 바로 아래(25~33%) {len(near)}건 — 경계 사례가 쌓이면 값 재검토')
print('\n판독 기준')
print(' · 합성 Answer: 줄이 0이 아니면 → generic 경로가 되살아났는지 확인')
print(' · SEARCH-ABANDONMENT이 여전히 0이면 → 포기 어법이 또 바뀐 것(NOTHING_FOUND 갱신)')
print(' · DEAD-END FALLBACK이 턴당 0.05 넘으면 → PRIOR_DEAD_ENDS_REQUIRED=1이 낮은 것')
print(' · standing down이 NUMERIC 재프롬프트보다 훨씬 많으면 → 관문이 너무 빡빡')
print(' · 패치 전 기준(턴당): NUMERIC 재프롬프트 0.051 / AUTO-RECOVER 0.080 /')
print(' SEARCH-ABANDONMENT 0.010 / 합성 Answer: 0.009')
if __name__ == '__main__':
main()