-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathanalyze.py
More file actions
325 lines (265 loc) · 9.78 KB
/
Copy pathanalyze.py
File metadata and controls
325 lines (265 loc) · 9.78 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
#!/usr/bin/env python3
"""
RemembrallMCP Benchmark Analyzer
Parses Claude Code conversation logs and compares agent performance
with and without RemembrallMCP on identical coding tasks.
Usage:
python benchmarks/analyze.py
python benchmarks/analyze.py --report benchmarks/reports/run-001.md
Reads runs.json for conversation mappings, finds JSONL logs in
~/.claude/projects/, and generates comparison reports.
"""
import json
import os
import sys
from pathlib import Path
from datetime import datetime
BENCHMARKS_DIR = Path(__file__).parent
RUNS_FILE = BENCHMARKS_DIR / "runs.json"
TASKS_FILE = BENCHMARKS_DIR / "tasks.toml"
CLAUDE_DIR = Path.home() / ".claude" / "projects"
def find_conversation_jsonl(conversation_id: str) -> Path | None:
"""Search ~/.claude/projects/ for a conversation JSONL file."""
for project_dir in CLAUDE_DIR.iterdir():
if not project_dir.is_dir():
continue
conv_dir = project_dir / "conversations"
if not conv_dir.exists():
continue
jsonl = conv_dir / f"{conversation_id}.jsonl"
if jsonl.exists():
return jsonl
return None
def parse_conversation(jsonl_path: Path) -> dict:
"""Extract metrics from a Claude Code conversation JSONL."""
messages = []
with open(jsonl_path) as f:
for line in f:
line = line.strip()
if not line:
continue
try:
messages.append(json.loads(line))
except json.JSONDecodeError:
continue
total_input = 0
total_output = 0
total_cache_read = 0
total_cache_creation = 0
tool_calls = 0
turns = 0
first_ts = None
last_ts = None
for msg in messages:
# Extract timestamp
ts = msg.get("timestamp")
if ts:
if first_ts is None:
first_ts = ts
last_ts = ts
# Only count assistant messages for usage
if msg.get("role") != "assistant":
continue
turns += 1
# Extract token usage
usage = msg.get("usage", {})
if not usage:
# Sometimes nested under message
usage = msg.get("message", {}).get("usage", {})
total_input += usage.get("input_tokens", 0)
total_output += usage.get("output_tokens", 0)
total_cache_read += usage.get("cache_read_input_tokens", 0)
total_cache_creation += usage.get("cache_creation_input_tokens", 0)
# Count tool calls in content blocks
content = msg.get("content", [])
if isinstance(content, list):
for block in content:
if isinstance(block, dict) and block.get("type") == "tool_use":
tool_calls += 1
# Wall clock time
wall_clock_s = 0
if first_ts and last_ts:
try:
t0 = datetime.fromisoformat(first_ts.replace("Z", "+00:00"))
t1 = datetime.fromisoformat(last_ts.replace("Z", "+00:00"))
wall_clock_s = (t1 - t0).total_seconds()
except (ValueError, TypeError):
pass
return {
"input_tokens": total_input,
"output_tokens": total_output,
"cache_read_tokens": total_cache_read,
"cache_creation_tokens": total_cache_creation,
"total_tokens": total_input + total_output + total_cache_read + total_cache_creation,
"tool_calls": tool_calls,
"turns": turns,
"wall_clock_s": round(wall_clock_s, 1),
}
def load_tasks() -> dict:
"""Load task definitions from tasks.toml."""
# Minimal TOML parser for our simple format
try:
import tomllib
except ImportError:
try:
import tomli as tomllib
except ImportError:
print("Warning: No TOML parser available. Install tomli: pip install tomli")
return {}
with open(TASKS_FILE, "rb") as f:
return tomllib.load(f)
def delta_str(without: float, with_val: float) -> str:
"""Format a percentage delta."""
if without == 0:
return "N/A"
pct = ((with_val - without) / without) * 100
sign = "+" if pct > 0 else ""
return f"{sign}{pct:.1f}%"
def generate_report(runs: list[dict], tasks: dict) -> str:
"""Generate a markdown comparison report."""
task_lookup = {}
for task in tasks.get("tasks", []):
task_lookup[task["id"]] = task
# Group runs by task_id
by_task = {}
for run in runs:
tid = run["task_id"]
if tid not in by_task:
by_task[tid] = {"with": None, "without": None}
by_task[tid][run["mode"]] = run
lines = [
"# RemembrallMCP Benchmark Results",
"",
f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M')}",
"",
]
aggregate_without = {
"input_tokens": 0, "output_tokens": 0, "total_tokens": 0,
"tool_calls": 0, "turns": 0, "wall_clock_s": 0,
}
aggregate_with = {
"input_tokens": 0, "output_tokens": 0, "total_tokens": 0,
"tool_calls": 0, "turns": 0, "wall_clock_s": 0,
}
task_count = 0
for tid, pair in by_task.items():
task_def = task_lookup.get(tid, {})
task_name = task_def.get("name", tid)
lines.append(f"## {task_name}")
lines.append("")
if task_def.get("why"):
lines.append(f"*{task_def['why']}*")
lines.append("")
lines.append(f"**Prompt:** {task_def.get('prompt', 'N/A')}")
lines.append("")
if not pair["with"] or not pair["without"]:
missing = "with" if not pair["with"] else "without"
lines.append(f"> Missing `{missing}` run. Skipping comparison.")
lines.append("")
continue
m_without = pair["without"].get("metrics", {})
m_with = pair["with"].get("metrics", {})
if not m_without or not m_with:
lines.append("> Metrics not yet collected. Run the analyzer after recording conversation IDs.")
lines.append("")
continue
lines.append("| Metric | Without RemembrallMCP | With RemembrallMCP | Delta |")
lines.append("|--------|----------------------|---------------------|-------|")
rows = [
("Input tokens", "input_tokens"),
("Output tokens", "output_tokens"),
("Total tokens", "total_tokens"),
("Tool calls", "tool_calls"),
("Turns", "turns"),
("Wall clock (s)", "wall_clock_s"),
]
for label, key in rows:
w = m_without.get(key, 0)
r = m_with.get(key, 0)
lines.append(f"| {label} | {w:,} | {r:,} | {delta_str(w, r)} |")
# Accuracy (manual)
acc_without = pair["without"].get("accuracy")
acc_with = pair["with"].get("accuracy")
if acc_without is not None and acc_with is not None:
lines.append(f"| Accuracy | {acc_without} | {acc_with} | |")
lines.append("")
# Accumulate aggregates
for key in aggregate_without:
aggregate_without[key] += m_without.get(key, 0)
aggregate_with[key] += m_with.get(key, 0)
task_count += 1
# Aggregate summary
if task_count > 0:
lines.append("## Aggregate")
lines.append("")
lines.append("| Metric | Without RemembrallMCP | With RemembrallMCP | Delta |")
lines.append("|--------|----------------------|---------------------|-------|")
for label, key in [
("Total tokens", "total_tokens"),
("Total tool calls", "tool_calls"),
("Total turns", "turns"),
("Total time (s)", "wall_clock_s"),
]:
w = aggregate_without[key]
r = aggregate_with[key]
lines.append(f"| {label} | {w:,} | {r:,} | {delta_str(w, r)} |")
lines.append("")
return "\n".join(lines)
def main():
if not RUNS_FILE.exists():
print(f"No runs file found at {RUNS_FILE}")
print("Record runs first. See benchmarks/README.md for instructions.")
sys.exit(1)
with open(RUNS_FILE) as f:
data = json.load(f)
runs = data.get("runs", [])
if not runs:
print("No runs recorded yet.")
print("See benchmarks/README.md for how to record benchmark runs.")
sys.exit(0)
# Enrich runs with parsed metrics
enriched = False
for run in runs:
if "metrics" in run:
continue # Already parsed
conv_id = run.get("conversation_id")
if not conv_id:
continue
jsonl = find_conversation_jsonl(conv_id)
if not jsonl:
print(f"Warning: Could not find conversation {conv_id}")
continue
print(f"Parsing {conv_id} ({run['task_id']}, {run['mode']})...")
run["metrics"] = parse_conversation(jsonl)
enriched = True
# Save enriched data back
if enriched:
with open(RUNS_FILE, "w") as f:
json.dump(data, f, indent=2)
print()
# Load tasks for labels
tasks = load_tasks()
# Generate report
report = generate_report(runs, tasks)
print(report)
# Write report file
report_arg = None
for i, arg in enumerate(sys.argv):
if arg == "--report" and i + 1 < len(sys.argv):
report_arg = sys.argv[i + 1]
if report_arg:
report_path = Path(report_arg)
report_path.parent.mkdir(parents=True, exist_ok=True)
with open(report_path, "w") as f:
f.write(report)
print(f"\nReport written to {report_path}")
else:
# Auto-save to reports/
ts = datetime.now().strftime("%Y%m%d-%H%M")
report_path = BENCHMARKS_DIR / "reports" / f"report-{ts}.md"
report_path.parent.mkdir(parents=True, exist_ok=True)
with open(report_path, "w") as f:
f.write(report)
print(f"\nReport saved to {report_path}")
if __name__ == "__main__":
main()