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
This commit is contained in:
@@ -2,116 +2,137 @@
|
||||
"""
|
||||
가드 발동률 실측 — 2026-09-04 가드 패치(db6a863 / 10e54d3 / b2075c6) 이후 구간을 잰다.
|
||||
|
||||
왜 필요한가. 이번 패치들은 전부 실사용 로그를 표본으로 보정했지만, 보정 자체가 맞았는지는
|
||||
"패치 이후 로그"로만 확인할 수 있다([[feedback_calibrate_on_real_data]]). 특히 두 가지가
|
||||
반대 방향으로 어긋날 수 있다:
|
||||
· 관련성 관문(sourceCoversQuery)이 너무 헐거우면 → NUMERIC-GROUNDING이 계속 맞는 답을 지운다
|
||||
· 너무 빡빡하면 → 진짜 날조가 그대로 통과한다(standing down이 과하게 잦아진다)
|
||||
왜 필요한가. 이번 패치들은 전부 실사용 로그를 표본으로 보정했지만, 보정이 맞았는지는
|
||||
"패치 이후 로그"로만 확인할 수 있다([[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 --all # 로그 전체(패치 전 비교용)
|
||||
|
||||
기준선은 .smallclaw/logs/audit/guard-baseline.json 에 줄 번호 + 꼬리 지문으로 박혀 있다.
|
||||
로그가 로테이션되면 지문이 안 맞고, 그때는 경고를 내고 전체를 잰다.
|
||||
python3 scripts/guard-firing-audit.py # 기준선 이후 증가분
|
||||
python3 scripts/guard-firing-audit.py --total # 누적 전체(기준선 무시)
|
||||
"""
|
||||
|
||||
import glob
|
||||
import gzip
|
||||
import json
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from collections import Counter
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
LOG = os.path.join(ROOT, '.smallclaw/logs/gateway.log')
|
||||
LOG_GLOB = os.path.join(ROOT, '.smallclaw/logs/gateway.log*')
|
||||
BASELINE = os.path.join(ROOT, '.smallclaw/logs/audit/guard-baseline.json')
|
||||
|
||||
# 세는 대상. (라벨, 정규식, 이번 패치와의 관계)
|
||||
# (키, 라벨, 정규식, 이번 패치와의 관계)
|
||||
PATTERNS = [
|
||||
('NUMERIC standing down', r'NUMERIC-GROUNDING: standing down', 'new'),
|
||||
('NUMERIC 재프롬프트', r'NUMERIC-GROUNDING POST-CHECK', 'should-drop'),
|
||||
('SEARCH-ABANDONMENT', r'SEARCH-ABANDONMENT POST-CHECK', 'was-dead'),
|
||||
('DEAD-END FALLBACK', r'DEAD-END FALLBACK POST-CHECK', 'new'),
|
||||
('SYCOPHANTIC', r'SYCOPHANTIC-CONFIRMATION POST-CHECK', 'new'),
|
||||
('AUTO-RECOVER', r'\[v2\] AUTO-RECOVER \(', 'baseline'),
|
||||
('EMPTY-GROUNDING', r'EMPTY-GROUNDING POST-CHECK', 'baseline'),
|
||||
('INTENT-ONLY', r'INTENT-ONLY POST-CHECK', 'baseline'),
|
||||
('MESSAGING', r'MESSAGING POST-CHECK', 'baseline'),
|
||||
('STALE-VERSION QUERY', r'STALE-VERSION QUERY', 'baseline'),
|
||||
('중복 도구호출 SKIP', r'SKIP: duplicate tool call', 'baseline'),
|
||||
('합성 Answer: 줄', r'TOOL OK: Answer: The current', 'should-be-0'),
|
||||
('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'),
|
||||
]
|
||||
|
||||
USER_RE = re.compile(r'^\[v2\] USER \[')
|
||||
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 load_lines():
|
||||
if not os.path.exists(LOG):
|
||||
sys.exit(f'로그가 없습니다: {LOG}')
|
||||
return open(LOG, errors='replace').read().splitlines()
|
||||
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 slice_from_baseline(lines):
|
||||
"""기준선 이후만 잘라낸다. 로테이션됐으면 (전체, False)."""
|
||||
if not os.path.exists(BASELINE):
|
||||
return lines, False
|
||||
meta = json.load(open(BASELINE))
|
||||
n = meta['baselineLines']
|
||||
if len(lines) < n:
|
||||
return lines, False # 로테이션돼 짧아졌다
|
||||
fp = hashlib.sha256('\n'.join(lines[max(0, n - 30):n]).encode()).hexdigest()[:16]
|
||||
if fp != meta['tailFingerprint']:
|
||||
return lines, False
|
||||
return lines[n:], True
|
||||
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', {})
|
||||
|
||||
def main():
|
||||
lines = load_lines()
|
||||
if '--all' in sys.argv:
|
||||
seg, anchored = lines, False
|
||||
scope = '로그 전체'
|
||||
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:
|
||||
seg, anchored = slice_from_baseline(lines)
|
||||
scope = '기준선 이후' if anchored else '로그 전체 (⚠ 기준선 유실 — 로테이션된 듯)'
|
||||
counts, label = total, '누적 전체'
|
||||
|
||||
turns = sum(1 for l in seg if USER_RE.match(l))
|
||||
print(f'구간: {scope} — {len(seg):,}줄, 사용자 턴 {turns}건\n')
|
||||
if turns == 0:
|
||||
print('아직 이 구간에 대화가 없습니다. 며칠 더 쓰고 다시 도세요.')
|
||||
turns = counts.get('user_turns', 0)
|
||||
print(f'구간: {label} — 사용자 턴 {turns}건\n')
|
||||
if turns <= 0:
|
||||
print('아직 이 구간에 대화가 거의 없습니다. 며칠 더 쓰고 다시 도세요.')
|
||||
return
|
||||
|
||||
print(f'{"가드":<24}{"발동":>6}{"턴당":>9} 비고')
|
||||
print('-' * 62)
|
||||
note = {'new': '이번에 추가', 'should-drop': '줄어야 정상', 'was-dead': '패치 전 0건이었음',
|
||||
'should-be-0': '0이어야 정상', 'baseline': ''}
|
||||
for label, pat, kind in PATTERNS:
|
||||
c = sum(1 for l in seg if re.search(pat, l))
|
||||
print(f'{label:<24}{c:>6}{c / turns:>9.2f} {note[kind]}')
|
||||
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 = [m.group(1) for l in seg for m in [FIG_RE.search(l)] if m]
|
||||
# 지워진 수치가 실제로 무엇이었는지 — 사용자가 원한 답이 지워졌는지는 눈으로만 판정된다.
|
||||
figs = FIG_RE.findall(text)
|
||||
if figs:
|
||||
print(f'\n지워진 수치 (총 {len(figs)}건, 최근 12건):')
|
||||
print(f'\n지워진 수치 (누적 {len(figs)}건, 최근 12건):')
|
||||
for f in figs[-12:]:
|
||||
print(f' · {f}')
|
||||
print(' ↑ 사용자가 원했던 답이 여기 섞여 있으면 관련성 관문이 아직 헐겁다.')
|
||||
|
||||
covs = [int(m.group(1)) for l in seg for m in [COV_RE.search(l)] if m]
|
||||
covs = sorted(int(m) for m in COV_RE.findall(text))
|
||||
if covs:
|
||||
print(f'\nstanding down 커버리지 분포: {sorted(covs)}')
|
||||
print(f' 중앙값 {sorted(covs)[len(covs)//2]}% (임계값 34%)')
|
||||
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(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(' · 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__':
|
||||
|
||||
Reference in New Issue
Block a user