diff --git a/scripts/guard-firing-audit.py b/scripts/guard-firing-audit.py index 2fc5f90..5e4f4f3 100755 --- a/scripts/guard-firing-audit.py +++ b/scripts/guard-firing-audit.py @@ -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__':