claude-harness/tests/test-retro-metrics.sh
andrey.epifantsev dde575673d feat(retro): retro_metrics.py — срез метрик, кандидаты в поправки владельца, сравнение с прошлым ретро (#29)
Синтетические сообщения об ошибке API (model <synthetic>) не считаются ходом модели (O-18, живой прогон).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:21:53 +04:00

133 lines
10 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# retro_metrics.py: срез метрик по синтетическим транскриптам (спека harness-retro, #29).
M="$ROOT/docs/retro/scripts/retro_metrics.py"
P="$TMP/proj"; CR="$TMP/cc/projects"; mkdir -p "$P/.git" "$P/.claude" "$P/hooks" "$CR"
echo 'hooks/' > "$P/.claude/code-paths"
cat > "$TMP/gen.py" <<'PY'
import json, os, re, sys
P, CR = sys.argv[1], sys.argv[2]
slug = lambda p: re.sub(r"[^A-Za-z0-9]", "-", p)
def w(d, name, recs):
os.makedirs(os.path.join(CR, d), exist_ok=True)
with open(os.path.join(CR, d, name), "w", encoding="utf-8") as fh:
for r in recs:
fh.write((r if isinstance(r, str) else json.dumps(r, ensure_ascii=False)) + "\n")
def u(text, ts, cwd=P, **kw):
return dict({"type": "user", "timestamp": ts, "cwd": cwd, "message": {"role": "user", "content": text}}, **kw)
def tr(tid, text, ts):
return {"type": "user", "timestamp": ts, "cwd": P,
"message": {"content": [{"type": "tool_result", "tool_use_id": tid, "content": text}]}}
def a(mid, content, ts, cwd=P, usage=None):
m = {"id": mid, "role": "assistant", "content": content}
if usage:
m["usage"] = usage
return {"type": "assistant", "timestamp": ts, "cwd": cwd, "message": m}
def tu(tid, tool, **inp):
return {"type": "tool_use", "id": tid, "name": tool, "input": inp}
A = slug(P)
T = "2026-11-02T10:%02d:00Z"
U1 = {"input_tokens": 100, "cache_creation_input_tokens": 1000, "cache_read_input_tokens": 50000, "output_tokens": 10}
w(A, "s1.jsonl", [
u("сделай X", T % 0),
a("m1", [{"type": "text", "text": "Начинаю."}], T % 1, usage=U1),
a("m1", [tu("t0", "Skill", skill="role", args="dev")], T % 1, usage=U1),
a("m2", [tu("t1", "Read", file_path=P + "/hooks/a.py"), tu("t2", "Read", file_path=P + "/docs/x.md")], T % 2),
a("m3", [tu("t3", "mcp__codegraph__codegraph_explore", query="x"), tu("t4", "mcp__codegraph__codegraph_explore", query="y"),
tu("t5", "Bash", command="grep -rn x hooks/"), tu("t6", "Bash", command="codegraph init"),
tu("t7", "Agent", model="sonnet", prompt="p"), tu("t8", "Agent", prompt="q"),
tu("t9", "Bash", command="echo x > f"), tu("t10", "Bash", command="cat hooks/require-plan.sh")], T % 3),
tr("t9", 'PreToolUse:Bash hook error: ["$HOME/.claude/hooks/guard-main.sh"]: BLOCKED [guard-main]: запись в f', T % 3),
tr("t10", 'echo "BLOCKED: editing without a plan" >&2', T % 3),
tr("t6", "Permission for this action was denied by the Claude Code auto mode classifier. Reason: [Self-Modification]. If", T % 3),
tr("t5", "The user doesn't want to proceed with this tool use. The tool use was rejected", T % 3),
u("<command-name>/clear</command-name>", T % 4),
u("служебное", T % 4, isMeta=True),
u("[Request interrupted by user]", T % 4),
a("m4", [{"type": "text", "text": "Вхожу в worktree."}, tu("t11", "EnterWorktree", name="x")], T % 5),
u("почему мне нужно это каждый раз подтверждать?", T % 6),
u("перечитай память", T % 7),
a("m5", [tu("t12", "Bash", command="git branch -m feat/x")], T % 8),
u("нет, tea login add --token 0123456789abcdef0123456789abcdef01234567 не так", T % 9),
{"type": "system", "subtype": "compact_boundary", "timestamp": T % 9},
"not json",
"[]",
])
W = P + "/.claude/worktrees/x"
w(A + "--claude-worktrees-x", "s2.jsonl", [
u("ок", "2026-11-03T09:00:00Z", cwd=W),
{"type": "assistant", "timestamp": "2026-11-03T09:00:30Z", "cwd": W, "isApiErrorMessage": True, # не ход модели
"message": {"id": "syn-1", "model": "<synthetic>", "role": "assistant", "content": [{"type": "text", "text": "API Error"}],
"usage": {"input_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 0}}},
a("n1", [tu("x1", "Bash", command='grep "unbalanced hooks/a.py')], "2026-11-03T09:01:00Z", cwd=W,
usage={"input_tokens": 0, "cache_creation_input_tokens": 6000, "cache_read_input_tokens": 50000, "output_tokens": 0}),
])
s5 = [u("да", "2026-11-04T09:00:00Z", cwd=P + ".wt/y"),
a("k1", [tu("y1", "Grep", pattern="x")], "2026-11-04T09:01:00Z", cwd=P + ".wt/y",
usage={"input_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 61000, "output_tokens": 0})]
w(slug(P + ".wt/y"), "s5.jsonl", s5)
w(A, "s5.jsonl", s5[:1]) # усечённая копия той же сессии — берётся больший файл
w(A, "s0.jsonl", [u("старое", "2026-10-01T09:00:00Z")])
w(A + "-tools", "s3.jsonl", [u("чужое", "2026-11-02T09:00:00Z", cwd=P + "-tools")])
os.makedirs(os.path.join(CR, A, "memory"), exist_ok=True)
open(os.path.join(CR, A, "memory", "MEMORY.md"), "w").write("x" * 100)
open(os.path.join(CR, A, "memory", "note.md"), "w").write("y" * 50)
PY
python3 "$TMP/gen.py" "$P" "$CR"
J="$TMP/m.json"
python3 "$M" "$P/" --since 2026-11-01 --projects-root "$CR" --out "$J"; rc=$?
assert_eq "срез: exit 0" "$rc" "0"
q(){ python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); m=d["metrics"]; print(eval(sys.argv[2]))' "$J" "$1"; }
assert_eq "схема 1" "$(q 'd["schema"]')" "1"
assert_eq "сессии: s1, s2 (worktree), s5 (.wt) — без старой и чужой" "$(q 'm["sessions"]')" "3"
assert_eq "каталоги транскриптов среза" "$(q 'len(d["sources"]["dirs"])')" "3"
assert_eq "битые строки" "$(q 'd["sources"]["bad_lines"]')" "1"
assert_eq "промпты владельца без служебных и прерываний" "$(q 'm["prompts"]')" "6"
assert_eq "прерывания" "$(q 'm["interrupts"]')" "1"
assert_eq "ходы — по message.id" "$(q 'm["turns"]')" "7"
assert_eq "вызовы инструментов" "$(q 'm["tool_calls"]')" "15"
assert_eq "токены вывода один раз на сообщение" "$(q 'm["tokens"]["output"]')" "10"
assert_eq "стартовый контекст" "$(q 'sorted(m["start_context"].items())')" "[('max', 61000), ('median', 56000), ('min', 51100)]"
assert_eq "блокировки только по ошибке хука" "$(q 'm["hook_blocks"], m["hook_blocks_by_hook"]')" "(1, {'guard-main(Bash)': 1})"
assert_eq "отказы классификатора по причине" "$(q 'm["classifier_by_reason"]')" "{'Self-Modification': 1}"
assert_eq "отказы владельца" "$(q 'm["owner_denials"]')" "1"
assert_eq "codegraph против чтения кода" "$(q 'sorted(m["codegraph"].items())')" "[('code_reads', 5), ('explore', 2), ('explore_share', 0.29), ('init', 1)]"
assert_eq "роли" "$(q 'sorted(m["roles"].items())')" "[('dev', 1), ('не выбрана', 2)]"
assert_eq "сабагенты по моделям" "$(q 'sorted(m["subagent_models"].items())')" "[('sonnet', 1), ('не указана', 1)]"
assert_eq "skills" "$(q 'm["skills"]')" "{'role': 1}"
assert_eq "компакции, перечитай" "$(q 'm["compactions"], m["recall"]')" "(1, 1)"
assert_eq "кандидаты в поправки" "$(q 'm["corrections"]')" "4"
assert_eq "память" "$(q 'sorted(m["memory"].items())')" "[('bytes', 150), ('files', 2), ('index_bytes', 100)]"
assert_eq "памяти нет — нули" "$(python3 -c "import sys; sys.path.insert(0, '$ROOT/docs/retro/scripts'); import retro_metrics; print(sorted(retro_metrics.memory_stats('$TMP/none', '$P').items()))")" "[('bytes', 0), ('files', 0), ('index_bytes', 0)]"
# кандидаты в поправки: контекст и redact
C="$TMP/corr.jsonl"
python3 "$M" "$P" --since 2026-11-01 --projects-root "$CR" --corrections > "$C"
assert_eq "кандидатов 4" "$(grep -c . "$C")" "4"
assert_eq "«почему» после EnterWorktree" "$(python3 -c 'import json,sys; print([c["action"] for c in map(json.loads, open(sys.argv[1])) if "почему" in c["text"]])' "$C")" "['EnterWorktree']"
assert_eq "последнее действие Bash" "$(python3 -c 'import json,sys; print([c["action"] for c in map(json.loads, open(sys.argv[1])) if c["text"].startswith("нет")])' "$C")" "['Bash: git branch -m feat/x']"
assert_eq "токен замазан" "$(grep -c '0123456789abcdef' "$C")" "0"
assert_grep "токен заменён на <redacted>" '<redacted>' "$C"
assert_eq "виды кандидатов" "$(python3 -c 'import json,sys; print(sorted(set(c["kind"] for c in map(json.loads, open(sys.argv[1])))))' "$C")" "['denial', 'interrupt', 'prompt']"
# сравнение с прошлым ретро
cat > "$TMP/prev.json" <<'EOF'
{"schema": 1, "metrics": {"sessions": 44, "turns": {"value": 12121, "note": "по строкам"}, "cd_main": {"retired": "хуки 0.4.0"}}}
EOF
md=$(python3 "$M" "$P" --since 2026-11-01 --projects-root "$CR" --markdown --prev "$TMP/prev.json")
assert_grep "строка с разницей" '^\| Сессий \| 3 \| 44 \| -41 \|$' <(printf '%s' "$md")
assert_grep "новая метрика — прочерки" '^\| Кандидатов в поправки владельца \| 4 \| — \| — \|$' <(printf '%s' "$md")
assert_grep "снятая метрика" '^\| cd в основной checkout на main \| — \| — \| не считается: хуки 0.4.0 \|$' <(printf '%s' "$md")
assert_grep "метрика с другим способом подсчёта" '^\| Ходов модели \| 7 \| 12 121 \(по строкам\) \| — \|$' <(printf '%s' "$md")
# ложные блокировки по разметке
cat > "$TMP/labels.json" <<'EOF'
{"home": "/h", "layout": [], "cases": {"x-001": {"expected": "allow", "why": "a"}, "x-002": {"expected": "allow", "why": "b"},
"x-003": {"expected": "allow", "why": "c", "known": "RT-21"}, "x-004": {"expected": "block", "why": "d"}, "x-005": {"skip": "s"}}}
EOF
python3 "$M" "$P" --since 2026-11-01 --projects-root "$CR" --labels "$TMP/labels.json" --out "$J"
assert_eq "ложные блокировки" "$(q 'sorted(m["false_blocks"].items())')" "[('false', 3), ('fixed', 2), ('known', 1), ('labelled', 4), ('share', 0.75), ('skipped', 1)]"
# период по умолчанию — от даты последнего ретро
H="$TMP/harness"; mkdir -p "$H/docs/retro"; touch "$H/docs/retro/2026-11-03.md" "$H/docs/retro/2026-10-11.md"
python3 "$M" "$P" --harness "$H" --projects-root "$CR" --out "$J"
assert_eq "период с последнего ретро" "$(q 'd["period"]["since"], m["sessions"]')" "('2026-11-03', 2)"
# нет сессий
err=$(python3 "$M" "$P" --since 2030-01-01 --projects-root "$CR" 2>&1 >/dev/null); rc=$?
assert_eq "нет сессий: exit 1" "$rc" "1"
assert_grep "сообщение со slug" 'proj' <(printf '%s' "$err")