narcolepticchicken commited on
Commit
91db96b
·
verified ·
1 Parent(s): 860427d

Upload aco/benchmark.py

Browse files
Files changed (1) hide show
  1. aco/benchmark.py +482 -0
aco/benchmark.py ADDED
@@ -0,0 +1,482 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ACO Benchmark Suite
2
+
3
+ Evaluates the Agent Cost Optimizer across 5 task categories:
4
+ A. Coding agent tasks
5
+ B. Research agent tasks
6
+ C. Tool-use tasks
7
+ D. Document/contract/QA tasks
8
+ E. Long-horizon agent tasks
9
+
10
+ For each, measures: task success, LLM calls, tool calls, retries, cost, latency,
11
+ cache hit rate, context tokens, false-DONE rate.
12
+
13
+ Baselines compared:
14
+ A. always frontier model
15
+ B. always cheap model
16
+ C. static model routing
17
+ D. prompt-only router
18
+
19
+ Primary metric: cost reduction at equal or better task success.
20
+ Secondary: cost per completed artifact.
21
+ """
22
+
23
+ import json, time, hashlib
24
+ from typing import Dict, List, Tuple, Optional, Any, Callable
25
+ from dataclasses import dataclass, field
26
+ from collections import defaultdict
27
+
28
+
29
+ # =============================================================================
30
+ # Core Metrics & Types
31
+ # =============================================================================
32
+
33
+ @dataclass
34
+ class StepMetrics:
35
+ step_num: int
36
+ model_used: str
37
+ provider: str
38
+ input_tokens: int
39
+ output_tokens: int
40
+ reasoning_tokens: int = 0
41
+ cache_hit: bool = False
42
+ context_size: int = 0
43
+ tool_calls: int = 0
44
+ tool_failures: int = 0
45
+ verifier_calls: int = 0
46
+ retries: int = 0
47
+ latency_ms: float = 0.0
48
+ cost_usd: float = 0.0
49
+
50
+
51
+ @dataclass
52
+ class TaskResult:
53
+ task_id: str
54
+ task_type: str # "coding", "research", "tool_use", "qa", "long_horizon"
55
+ success: bool
56
+ steps: List[StepMetrics] = field(default_factory=list)
57
+ total_llm_calls: int = 0
58
+ total_tool_calls: int = 0
59
+ total_retries: int = 0
60
+ total_verifier_calls: int = 0
61
+ total_cost_usd: float = 0.0
62
+ total_latency_ms: float = 0.0
63
+ false_done: bool = False
64
+ blocked: bool = False
65
+ artifact_hash: Optional[str] = None
66
+ error: Optional[str] = None
67
+
68
+
69
+ @dataclass
70
+ class RunReport:
71
+ config_name: str
72
+ config_description: str
73
+ results: List[TaskResult] = field(default_factory=list)
74
+
75
+ @property
76
+ def success_rate(self) -> float:
77
+ if not self.results: return 0.0
78
+ return sum(1 for r in self.results if r.success) / len(self.results)
79
+
80
+ @property
81
+ def avg_cost_per_success(self) -> float:
82
+ successes = [r.total_cost_usd for r in self.results if r.success]
83
+ if not successes: return float('inf')
84
+ return sum(successes) / len(successes)
85
+
86
+ @property
87
+ def avg_cost_per_task(self) -> float:
88
+ if not self.results: return 0.0
89
+ return sum(r.total_cost_usd for r in self.results) / len(self.results)
90
+
91
+ @property
92
+ def avg_llm_calls(self) -> float:
93
+ if not self.results: return 0.0
94
+ return sum(r.total_llm_calls for r in self.results) / len(self.results)
95
+
96
+ @property
97
+ def avg_tool_calls(self) -> float:
98
+ if not self.results: return 0.0
99
+ return sum(r.total_tool_calls for r in self.results) / len(self.results)
100
+
101
+ @property
102
+ def avg_retries(self) -> float:
103
+ if not self.results: return 0.0
104
+ return sum(r.total_retries for r in self.results) / len(self.results)
105
+
106
+ @property
107
+ def false_done_rate(self) -> float:
108
+ if not self.results: return 0.0
109
+ return sum(1 for r in self.results if r.false_done) / len(self.results)
110
+
111
+ def to_dict(self) -> Dict:
112
+ return {
113
+ "config_name": self.config_name,
114
+ "success_rate": self.success_rate,
115
+ "avg_cost_per_success": self.avg_cost_per_success,
116
+ "avg_cost_per_task": self.avg_cost_per_task,
117
+ "avg_llm_calls": self.avg_llm_calls,
118
+ "avg_tool_calls": self.avg_tool_calls,
119
+ "avg_retries": self.avg_retries,
120
+ "false_done_rate": self.false_done_rate,
121
+ "n_tasks": len(self.results),
122
+ }
123
+
124
+
125
+ # =============================================================================
126
+ # Benchmark Tasks (Synthetic to start — real traces follow)
127
+ # =============================================================================
128
+
129
+ CODING_TASKS = [
130
+ {"id": "code_1", "text": "Write a Python function to sort a list of dicts by a key",
131
+ "expected_tools": ["write_file", "run_test"], "difficulty": "easy"},
132
+ {"id": "code_2", "text": "Fix the race condition in this multithreaded queue",
133
+ "expected_tools": ["read_file", "grep", "write_file", "run_test"], "difficulty": "hard"},
134
+ {"id": "code_3", "text": "Add input validation to all API endpoints",
135
+ "expected_tools": ["grep", "read_file", "write_file", "run_test"], "difficulty": "medium"},
136
+ {"id": "code_4", "text": "Migrate from SQLAlchemy 1.4 to 2.0 async API",
137
+ "expected_tools": ["read_file", "grep", "write_file", "run_migration"], "difficulty": "hard"},
138
+ {"id": "code_5", "text": "Write unit tests for the User model",
139
+ "expected_tools": ["read_file", "write_file", "run_test"], "difficulty": "medium"},
140
+ ]
141
+
142
+ RESEARCH_TASKS = [
143
+ {"id": "research_1", "text": "What's the SOTA for cost-aware LLM routing?",
144
+ "expected_tools": ["search", "read_paper", "synthesize"], "difficulty": "medium"},
145
+ {"id": "research_2", "text": "Find all papers about speculative decoding applied to agents",
146
+ "expected_tools": ["search", "citation_graph", "synthesize"], "difficulty": "hard"},
147
+ {"id": "research_3", "text": "How does Anthropic's prompt caching work in detail?",
148
+ "expected_tools": ["search", "read_docs", "synthesize"], "difficulty": "easy"},
149
+ {"id": "research_4", "text": "Survey test-time compute allocation methods 2023-2025",
150
+ "expected_tools": ["search", "read_paper", "citation_graph", "synthesize"], "difficulty": "hard"},
151
+ {"id": "research_5", "text": "What datasets exist for training agent cost routers?",
152
+ "expected_tools": ["search", "read_paper", "synthesize"], "difficulty": "medium"},
153
+ ]
154
+
155
+ TOOL_USE_TASKS = [
156
+ {"id": "tool_1", "text": "What's the weather in Paris today?",
157
+ "tools_needed": False, "difficulty": "easy"},
158
+ {"id": "tool_2", "text": "Find me the top 5 restaurants in Tokyo and their ratings",
159
+ "tools_needed": True, "difficulty": "medium"},
160
+ {"id": "tool_3", "text": "Compare the GDP of France and Germany over the last decade",
161
+ "tools_needed": True, "difficulty": "medium"},
162
+ {"id": "tool_4", "text": "Translate 'hello' to Japanese",
163
+ "tools_needed": False, "difficulty": "easy"},
164
+ {"id": "tool_5", "text": "Calculate compound interest on $10,000 at 5% over 20 years",
165
+ "tools_needed": False, "difficulty": "easy"},
166
+ ]
167
+
168
+ QA_TASKS = [
169
+ {"id": "qa_1", "text": "What is the capital of France?", "needs_verifier": False, "difficulty": "easy"},
170
+ {"id": "qa_2", "text": "Explain the difference between TCP and UDP",
171
+ "needs_verifier": False, "difficulty": "easy"},
172
+ {"id": "qa_3", "text": "What are the implications of Article 22 of GDPR for automated decision-making?",
173
+ "needs_verifier": True, "difficulty": "hard"},
174
+ {"id": "qa_4", "text": "Review this contract for liability clauses",
175
+ "needs_verifier": True, "difficulty": "hard"},
176
+ {"id": "qa_5", "text": "Summarize the key findings from the IPCC AR6 report",
177
+ "needs_verifier": True, "difficulty": "medium"},
178
+ ]
179
+
180
+ LONG_HORIZON_TASKS = [
181
+ {"id": "long_1", "text": "Set up a complete CI/CD pipeline for a Python project",
182
+ "expected_steps": 8, "difficulty": "hard"},
183
+ {"id": "long_2", "text": "Research, design, and implement a caching layer for the API",
184
+ "expected_steps": 6, "difficulty": "hard"},
185
+ {"id": "long_3", "text": "Write a README, add tests, and set up pre-commit hooks",
186
+ "expected_steps": 5, "difficulty": "medium"},
187
+ {"id": "long_4", "text": "Migrate the database schema, update models, and add rollback scripts",
188
+ "expected_steps": 7, "difficulty": "hard"},
189
+ {"id": "long_5", "text": "Audit the codebase for security vulnerabilities and fix the top 3",
190
+ "expected_steps": 6, "difficulty": "hard"},
191
+ ]
192
+
193
+ ALL_TASKS = {
194
+ "coding": CODING_TASKS,
195
+ "research": RESEARCH_TASKS,
196
+ "tool_use": TOOL_USE_TASKS,
197
+ "qa": QA_TASKS,
198
+ "long_horizon": LONG_HORIZON_TASKS,
199
+ }
200
+
201
+
202
+ # =============================================================================
203
+ # Simulated Agent Harness (for benchmarking w/o real API keys)
204
+ # =============================================================================
205
+
206
+ PRICING = {
207
+ "gpt-4.1-mini": {"input": 0.15, "output": 0.60},
208
+ "gpt-4o": {"input": 2.50, "output": 10.00},
209
+ "claude-sonnet-4": {"input": 3.00, "output": 15.00},
210
+ "claude-haiku": {"input": 0.80, "output": 4.00},
211
+ "llama-3.1-8b": {"input": 0.06, "output": 0.06},
212
+ "qwen-2.5-7b": {"input": 0.07, "output": 0.07},
213
+ "gemini-flash": {"input": 0.075, "output": 0.30},
214
+ "local-tiny": {"input": 0.001, "output": 0.001},
215
+ }
216
+
217
+ TIER_MODELS = {
218
+ "cheap": "llama-3.1-8b",
219
+ "medium": "gpt-4.1-mini",
220
+ "frontier": "claude-sonnet-4",
221
+ }
222
+
223
+
224
+ class SimulatedAgent:
225
+ """
226
+ Simulated agent with configurable success probabilities per tier.
227
+ Used for benchmark without real API keys.
228
+ """
229
+
230
+ def __init__(self, success_rates: Optional[Dict[str, Dict[str, float]]] = None):
231
+ """
232
+ success_rates: {tier: {"coding": prob, "research": prob, ...}}
233
+ Defaults calibrated to realistic distributions.
234
+ """
235
+ self.success_rates = success_rates or {
236
+ "cheap": {"coding": 0.55, "research": 0.40, "tool_use": 0.50, "qa": 0.60, "long_horizon": 0.30},
237
+ "medium": {"coding": 0.78, "research": 0.65, "tool_use": 0.75, "qa": 0.85, "long_horizon": 0.55},
238
+ "frontier":{"coding": 0.92, "research": 0.85, "tool_use": 0.90, "qa": 0.95, "long_horizon": 0.80},
239
+ }
240
+ import random
241
+ self.rng = random.Random(42)
242
+
243
+ def run_task(self, task: Dict, tier: str, max_steps: int = 8,
244
+ use_tools: bool = True, use_verifier: bool = False) -> TaskResult:
245
+ """
246
+ Simulate running a task with given tier.
247
+ Returns TaskResult with simulated metrics.
248
+ """
249
+ task_type = task.get("type", "coding")
250
+ success_prob = self.success_rates[tier].get(task_type, 0.7)
251
+ difficulty = task.get("difficulty", "medium")
252
+
253
+ # Adjust success prob by difficulty
254
+ diff_mult = {"easy": 1.15, "medium": 1.0, "hard": 0.80}
255
+ success_prob = min(0.99, success_prob * diff_mult[difficulty])
256
+
257
+ model_id = TIER_MODELS[tier]
258
+ pricing = PRICING[model_id]
259
+
260
+ # Simulated step count
261
+ if difficulty == "easy":
262
+ steps = self.rng.randint(1, 3)
263
+ elif difficulty == "medium":
264
+ steps = self.rng.randint(2, 5)
265
+ else:
266
+ steps = self.rng.randint(3, min(max_steps, 8))
267
+
268
+ result = TaskResult(
269
+ task_id=task["id"],
270
+ task_type=task_type,
271
+ success=False,
272
+ )
273
+
274
+ for i in range(steps):
275
+ in_tokens = self.rng.randint(200, 3000)
276
+ out_tokens = self.rng.randint(50, 1500)
277
+ tool_calls = self.rng.randint(0, 3) if use_tools else 0
278
+ tool_failures = self.rng.randint(0, 1) if tool_calls > 0 else 0
279
+ verifier_calls = 1 if use_verifier else 0
280
+ retries = self.rng.randint(0, 2) if tool_failures > 0 else 0
281
+
282
+ cost = (in_tokens / 1_000_000 * pricing["input"] +
283
+ out_tokens / 1_000_000 * pricing["output"])
284
+
285
+ step = StepMetrics(
286
+ step_num=i+1,
287
+ model_used=model_id,
288
+ provider="simulated",
289
+ input_tokens=in_tokens,
290
+ output_tokens=out_tokens,
291
+ context_size=in_tokens + out_tokens,
292
+ tool_calls=tool_calls,
293
+ tool_failures=tool_failures,
294
+ verifier_calls=verifier_calls,
295
+ retries=retries,
296
+ latency_ms=in_tokens * 2 + out_tokens * 10,
297
+ cost_usd=cost,
298
+ )
299
+ result.steps.append(step)
300
+ result.total_cost_usd += cost
301
+ result.total_latency_ms += step.latency_ms
302
+ result.total_llm_calls += 1
303
+ result.total_tool_calls += tool_calls
304
+ result.total_retries += retries
305
+ result.total_verifier_calls += verifier_calls
306
+
307
+ result.success = self.rng.random() < (success_prob * (1 - 0.03 * result.total_retries))
308
+
309
+ if result.success:
310
+ result.artifact_hash = hashlib.md5(task["text"].encode()).hexdigest()[:16]
311
+
312
+ return result
313
+
314
+
315
+ # =============================================================================
316
+ # Benchmark Runner
317
+ # =============================================================================
318
+
319
+ class BenchmarkRunner:
320
+ """Runs all task types against all baseline configs and produces comparison."""
321
+
322
+ def __init__(self, agent: SimulatedAgent):
323
+ self.agent = agent
324
+ self.reports: Dict[str, RunReport] = {}
325
+
326
+ def run_config(self, name: str, config: Callable[[Dict], Tuple[str, bool, bool]],
327
+ description: str = ""):
328
+ """
329
+ Run a configuration across all tasks.
330
+
331
+ config(task): returns (tier, use_tools, use_verifier)
332
+ """
333
+ report = RunReport(name, description)
334
+
335
+ for task_type, tasks in ALL_TASKS.items():
336
+ for task in tasks:
337
+ task["type"] = task_type
338
+ tier, use_tools, use_verifier = config(task)
339
+ result = self.agent.run_task(task, tier, use_tools=use_tools. use_verifier=use_verifier)
340
+ report.results.append(result)
341
+
342
+ self.reports[name] = report
343
+ return report
344
+
345
+ def run_all_baselines(self):
346
+ """Run all 4 baseline configurations."""
347
+
348
+ # A. Always Frontier
349
+ print("Running A: Always Frontier...")
350
+ self.run_config(
351
+ "A_always_frontier",
352
+ lambda t: ("frontier", True, True),
353
+ "Always use frontier model with all tools and verifier"
354
+ )
355
+
356
+ # B. Always Cheap
357
+ print("Running B: Always Cheap...")
358
+ self.run_config(
359
+ "B_always_cheap",
360
+ lambda t: ("cheap", False, False),
361
+ "Always use cheap model without tools or verifier"
362
+ )
363
+
364
+ # C. Static Routing
365
+ print("Running C: Static Routing...")
366
+ self.run_config(
367
+ "C_static_routing",
368
+ lambda t: self._static_route(t),
369
+ "Static rules: easy→cheap, medium→medium, hard→frontier"
370
+ )
371
+
372
+ # D. Prompt-Only Router
373
+ print("Running D: Prompt-Only Router...")
374
+ self.run_config(
375
+ "D_prompt_router",
376
+ lambda t: self._prompt_route(t),
377
+ "Heuristic router based on task type + difficulty keywords"
378
+ )
379
+
380
+ # E. ACO v2 (trained router)
381
+ # Requires loaded models - placeholder
382
+
383
+ def _static_route(self, task: Dict) -> Tuple[str, bool, bool]:
384
+ diff = task.get("difficulty", "medium")
385
+ if diff == "easy": return ("cheap", False, False)
386
+ elif diff == "medium": return ("medium", True, False)
387
+ else: return ("frontier", True, True)
388
+
389
+ def _prompt_route(self, task: Dict) -> Tuple[str, bool, bool]:
390
+ text = task["text"].lower()
391
+ if any(kw in text for kw in ["fix", "debug", "migrate", "critical", "vulnerability"]):
392
+ return ("frontier", True, True)
393
+ elif any(kw in text for kw in ["test", "add", "write", "survey", "find"]):
394
+ return ("medium", True, False)
395
+ else:
396
+ return ("cheap", False, False)
397
+
398
+ def compare(self) -> Dict:
399
+ """Produce comparison table."""
400
+ baseline_label = "A_always_frontier"
401
+ if baseline_label not in self.reports:
402
+ return {"error": "Run baselines first"}
403
+
404
+ baseline = self.reports[baseline_label]
405
+ baseline_success = baseline.success_rate
406
+ baseline_cost_success = baseline.avg_cost_per_success
407
+
408
+ comparison = {}
409
+ for name, report in self.reports.items():
410
+ sr = report.success_rate
411
+ cs = report.avg_cost_per_success
412
+
413
+ if sr >= baseline_success * 0.95: # within 5% = iso-quality
414
+ cost_reduction = (baseline_cost_success - cs) / baseline_cost_success * 100
415
+ else:
416
+ cost_reduction = None # not iso-quality
417
+
418
+ comparison[name] = {
419
+ "description": report.config_description,
420
+ "success_rate": f"{sr:.1%}",
421
+ "cost_per_success": f"${cs:.4f}",
422
+ "cost_reduction_at_iso": f"{cost_reduction:.1f}%" if cost_reduction is not None else "N/A (quality drop)",
423
+ "avg_llm_calls": f"{report.avg_llm_calls:.1f}",
424
+ "avg_tool_calls": f"{report.avg_tool_calls:.1f}",
425
+ "false_done_rate": f"{report.false_done_rate:.1%}",
426
+ }
427
+
428
+ return comparison
429
+
430
+ def print_comparison(self):
431
+ """Pretty-print comparison table."""
432
+ comp = self.compare()
433
+ if "error" in comp:
434
+ print(comp["error"])
435
+ return
436
+
437
+ print(f"\n{'='*90}")
438
+ print(f"ACO BENCHMARK COMPARISON ({len(self.reports)} configs, 25 tasks)")
439
+ print(f"{'='*90}")
440
+ print(f"{'Config':<25} {'Success':>8} {'Cost/Success':>13} {'Cost Reduction':>15} {'LLM Calls':>10} {'Tools':>7} {'False-DONE':>10}")
441
+ print(f"{'-'*90}")
442
+
443
+ for name, stats in comp.items():
444
+ print(f"{name:<25} {stats['success_rate']:>8} {stats['cost_per_success']:>13} "
445
+ f"{stats['cost_reduction_at_iso']:>15} {stats['avg_llm_calls']:>10} "
446
+ f"{stats['avg_tool_calls']:>7} {stats['false_done_rate']:>10}")
447
+
448
+ print(f"{'='*90}")
449
+
450
+ # Find best iso-quality config
451
+ best = None
452
+ best_reduction = 0
453
+ for name, stats in comp.items():
454
+ if "N/A" not in stats["cost_reduction_at_iso"]:
455
+ pct = float(stats["cost_reduction_at_iso"].rstrip("%"))
456
+ if pct > best_reduction:
457
+ best_reduction = pct
458
+ best = name
459
+
460
+ if best:
461
+ print(f"\nBEST ISO-QUALITY CONFIG: {best} ({best_reduction:.1f}% cost reduction)")
462
+
463
+ def save_results(self, path: str):
464
+ """Save all results to JSON."""
465
+ data = {
466
+ name: report.to_dict()
467
+ for name, report in self.reports.items()
468
+ }
469
+ with open(path, "w") as f:
470
+ json.dump(data, f, indent=2)
471
+ print(f"\nResults saved to {path}")
472
+
473
+
474
+ # =============================================================================
475
+ # Quick test
476
+ # =============================================================================
477
+ if __name__ == "__main__":
478
+ agent = SimulatedAgent()
479
+ runner = BenchmarkRunner(agent)
480
+ runner.run_all_baselines()
481
+ runner.print_comparison()
482
+ runner.save_results("/tmp/benchmark_results.json")