wiki-speedrun / scripts /comprehensive_benchmark.py
jwlutz's picture
Initial commit: Wikipedia Speedrun benchmark with Flask dashboard
0e61be5
Raw
History Blame Contribute Delete
20.4 kB
#!/usr/bin/env python3
"""
Comprehensive benchmark comparing all agents on curated test cases.
Compares:
- Live embedding agent (greedy similarity)
- LLM agents (multiple models via OpenRouter)
Features:
- Incremental save: Results saved after each game (survives crashes)
- Resume support: Automatically skips already-completed games
- Use --fresh to start over and clear previous results
- Use --rerun-errors to rerun games that had API/network errors
- Use --rerun-losses to rerun games that hit max steps (didn't win)
Usage:
python scripts/comprehensive_benchmark.py
python scripts/comprehensive_benchmark.py --llm-only
python scripts/comprehensive_benchmark.py --free-only
python scripts/comprehensive_benchmark.py --fresh # Clear cache and start over
python scripts/comprehensive_benchmark.py --rerun-errors # Retry failed API calls
python scripts/comprehensive_benchmark.py --rerun-losses # Retry games that lost
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
from dataclasses import asdict, dataclass
from pathlib import Path
# Fix Windows console encoding
if sys.platform == "win32":
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
# Suppress warnings
os.environ["TF_CPP_MIN_LOG_LEVEL"] = "3"
os.environ["TF_ENABLE_ONEDNN_OPTS"] = "0"
import warnings
warnings.filterwarnings("ignore", category=UserWarning)
warnings.filterwarnings("ignore", category=DeprecationWarning)
# Add project root to path
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root))
import logging
logging.basicConfig(level=logging.WARNING)
from src.agents import get_agent
from src.game import GameEngine
# =============================================================================
# LOAD PROBLEMS FROM JSON
# =============================================================================
def load_benchmark_problems() -> list[tuple[str, str]]:
"""Load benchmark problems from JSON file."""
problems_path = project_root / "data" / "benchmark_problems.json"
if problems_path.exists():
with open(problems_path) as f:
data = json.load(f)
return [(p["start"], p["target"]) for p in data["problems"]]
else:
# Fallback to hardcoded if JSON doesn't exist
print(f"Warning: {problems_path} not found, using fallback problems")
return [
("Potato", "Barack Obama"),
("Chess", "Mathematics"),
("Moon", "Shakespeare"),
]
def load_model_tiers() -> dict[str, list[str]]:
"""Load model tiers from JSON file."""
problems_path = project_root / "data" / "benchmark_problems.json"
if problems_path.exists():
with open(problems_path) as f:
data = json.load(f)
return data.get("model_tiers", {})
return {}
def load_embedding_models() -> list[str]:
"""Load embedding models from JSON file."""
problems_path = project_root / "data" / "benchmark_problems.json"
if problems_path.exists():
with open(problems_path) as f:
data = json.load(f)
return data.get("embedding_models", ["all-MiniLM-L6-v2"])
return ["all-MiniLM-L6-v2"]
# Load from JSON
TEST_CASES = load_benchmark_problems()
MODEL_TIERS = load_model_tiers()
EMBEDDING_MODELS = load_embedding_models()
# =============================================================================
# LLM MODELS TO TEST (from JSON or fallback)
# =============================================================================
# Free models (test first to avoid costs)
FREE_MODELS = MODEL_TIERS.get("free", [
"google/gemini-2.0-flash-exp:free",
"mistralai/mistral-small-3.1-24b-instruct:free",
"meta-llama/llama-3.3-70b-instruct:free",
])
# Paid models (budget tier)
BUDGET_MODELS = MODEL_TIERS.get("budget", [
"openai/gpt-5-nano",
"google/gemini-2.0-flash-001",
"openai/gpt-4o-mini",
"deepseek/deepseek-v3.2",
])
# Paid models (fast tier)
FAST_MODELS = MODEL_TIERS.get("fast", [
"anthropic/claude-haiku-4.5",
"openai/gpt-5-mini",
"google/gemini-3-flash-preview",
])
# Paid models (premium tier)
PREMIUM_MODELS = MODEL_TIERS.get("premium", [
"anthropic/claude-sonnet-4.5",
"openai/gpt-5.2",
"google/gemini-3-pro-preview",
])
# =============================================================================
# RESULT TRACKING
# =============================================================================
@dataclass
class GameResult:
agent: str
start: str
target: str
won: bool
clicks: int
time_seconds: float
path: list[str]
error: str = ""
error_type: str = "" # "http_error", "timeout", "api_error", "game_error", "unknown"
traceback: str = "" # Full traceback for debugging
tokens_used: int = 0 # LLM token usage (0 for embedding agents)
@dataclass
class AgentSummary:
agent: str
games_played: int
wins: int
win_rate: float
avg_clicks_when_won: float
avg_time_seconds: float
total_time: float
def classify_error(e: Exception) -> tuple[str, str, str]:
"""Classify an exception into (error_type, error_message, traceback)."""
import traceback as tb
import requests
error_msg = str(e)
traceback_str = tb.format_exc()
# Classify by exception type
if isinstance(e, requests.exceptions.Timeout):
return "timeout", error_msg, traceback_str
elif isinstance(e, requests.exceptions.HTTPError):
return "http_error", error_msg, traceback_str
elif isinstance(e, requests.exceptions.RequestException):
return "network_error", error_msg, traceback_str
elif "API" in error_msg or "api" in error_msg or "OpenRouter" in error_msg:
return "api_error", error_msg, traceback_str
elif "rate limit" in error_msg.lower() or "429" in error_msg:
return "rate_limit", error_msg, traceback_str
elif "402" in error_msg or "credits" in error_msg.lower():
return "insufficient_credits", error_msg, traceback_str
elif "404" in error_msg or "not found" in error_msg.lower():
return "not_found", error_msg, traceback_str
elif isinstance(e, (ValueError, KeyError, IndexError)):
return "game_error", error_msg, traceback_str
else:
return "unknown", error_msg, traceback_str
# =============================================================================
# INCREMENTAL SAVE / RESUME
# =============================================================================
def get_jsonl_path(output_path: Path) -> Path:
"""Get the JSONL checkpoint file path from the final output path."""
return output_path.with_suffix(".jsonl")
def load_completed_games(jsonl_path: Path) -> tuple[set[tuple[str, str, str]], list[GameResult]]:
"""
Load previously completed games from JSONL checkpoint.
Returns:
(completed_keys, results) where completed_keys is set of (agent, start, target) tuples
"""
completed: set[tuple[str, str, str]] = set()
results: list[GameResult] = []
if not jsonl_path.exists():
return completed, results
with open(jsonl_path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
data = json.loads(line)
result = GameResult(**data)
results.append(result)
completed.add((result.agent, result.start, result.target))
except (json.JSONDecodeError, TypeError):
continue # Skip malformed lines
return completed, results
def append_result(jsonl_path: Path, result: GameResult) -> None:
"""Append a single result to the JSONL checkpoint file."""
with open(jsonl_path, "a", encoding="utf-8") as f:
f.write(json.dumps(asdict(result)) + "\n")
def run_game(agent_name: str, start: str, target: str, max_steps: int = 25, **kwargs) -> GameResult:
"""Run a single game and return result with detailed error tracking."""
try:
agent = get_agent(agent_name, **kwargs)
with GameEngine(visualize=False) as engine:
start_time = time.time()
result = engine.run(
agent=agent,
start=start,
target=target,
max_steps=max_steps,
)
elapsed = time.time() - start_time
# Get token usage from LLM agents
tokens_used = 0
if hasattr(agent, "get_stats"):
stats = agent.get_stats()
tokens_used = stats.get("total_tokens", 0)
return GameResult(
agent=agent.name,
start=start,
target=target,
won=result.won,
clicks=result.total_clicks,
time_seconds=round(elapsed, 2),
path=result.path,
tokens_used=tokens_used,
)
except Exception as e:
error_type, error_msg, traceback_str = classify_error(e)
return GameResult(
agent=agent_name,
start=start,
target=target,
won=False,
clicks=-1,
time_seconds=0,
path=[],
error=error_msg,
error_type=error_type,
traceback=traceback_str,
)
def summarize_results(results: list[GameResult], agent_name: str) -> AgentSummary:
"""Summarize results for an agent."""
agent_results = [r for r in results if r.agent == agent_name or agent_name in r.agent]
if not agent_results:
return AgentSummary(
agent=agent_name,
games_played=0,
wins=0,
win_rate=0,
avg_clicks_when_won=0,
avg_time_seconds=0,
total_time=0,
)
wins = [r for r in agent_results if r.won]
total_time = sum(r.time_seconds for r in agent_results)
return AgentSummary(
agent=agent_name,
games_played=len(agent_results),
wins=len(wins),
win_rate=len(wins) / len(agent_results) if agent_results else 0,
avg_clicks_when_won=sum(r.clicks for r in wins) / len(wins) if wins else 0,
avg_time_seconds=total_time / len(agent_results) if agent_results else 0,
total_time=round(total_time, 1),
)
def main():
parser = argparse.ArgumentParser(description="Comprehensive agent benchmark")
parser.add_argument("--llm-only", action="store_true", help="Only test LLM agents")
parser.add_argument("--skip-oracle", action="store_true", help="Skip oracle (slow)")
parser.add_argument("--free-only", action="store_true", help="Only test free LLM models")
parser.add_argument("--max-steps", type=int, default=25, help="Max steps per game")
parser.add_argument("--output", type=str, default="data/benchmark_results.json", help="Output file")
parser.add_argument("--cases", type=int, default=None, help="Limit number of test cases")
parser.add_argument("--fresh", action="store_true", help="Clear cached results and start fresh")
parser.add_argument("--rerun-errors", action="store_true", help="Rerun games that had errors")
parser.add_argument("--rerun-losses", action="store_true", help="Rerun games that lost (hit max steps)")
args = parser.parse_args()
test_cases = TEST_CASES[:args.cases] if args.cases else TEST_CASES
# Setup paths
output_path = project_root / args.output
output_path.parent.mkdir(exist_ok=True)
jsonl_path = get_jsonl_path(output_path)
# Load or clear cached results
if args.fresh and jsonl_path.exists():
jsonl_path.unlink()
print("Cleared cached results (--fresh)")
completed_games: set[tuple[str, str, str]] = set()
all_results: list[GameResult] = []
else:
completed_games, all_results = load_completed_games(jsonl_path)
# Filter out errors/losses for rerun
if args.rerun_errors or args.rerun_losses:
filtered_results = []
removed_count = 0
for r in all_results:
should_remove = False
if args.rerun_errors and r.error:
should_remove = True
if args.rerun_losses and not r.won and not r.error:
should_remove = True
if should_remove:
completed_games.discard((r.agent, r.start, r.target))
removed_count += 1
else:
filtered_results.append(r)
all_results = filtered_results
# Rewrite JSONL without the removed results
if removed_count > 0:
with open(jsonl_path, "w", encoding="utf-8") as f:
for r in all_results:
f.write(json.dumps(asdict(r)) + "\n")
print(f"Removed {removed_count} results for rerun")
if completed_games:
print(f"Resuming: {len(completed_games)} games already completed")
print("=" * 80)
print("COMPREHENSIVE WIKIPEDIA SPEEDRUN BENCHMARK")
print("=" * 80)
print(f"Test cases: {len(test_cases)}")
print(f"Embedding models: {len(EMBEDDING_MODELS)}")
print(f"Max steps: {args.max_steps}")
print()
# ==========================================================================
# EMBEDDING AGENTS (test multiple models)
# ==========================================================================
if not args.llm_only:
print("-" * 80)
print("TESTING EMBEDDING AGENTS")
print("-" * 80)
for model_name in EMBEDDING_MODELS:
model_short = model_name.split("/")[-1]
print(f"\n{model_short}:")
print(" Loading embedding model...")
# Pre-warm embedding model and get actual agent name
agent = get_agent("live", model_name=model_name)
agent._ensure_loaded()
# Use the agent's actual name for cache key (e.g., "live-all-MiniLM-L6-v2")
agent_name = agent.name
for i, (start, target) in enumerate(test_cases, 1):
# Skip if already completed
if (agent_name, start, target) in completed_games:
print(f" [{i:2}/{len(test_cases)}] {start:20}{target:20} : CACHED")
continue
result = run_game("live", start, target, args.max_steps, model_name=model_name)
all_results.append(result)
append_result(jsonl_path, result) # Incremental save
if result.error:
print(f" [{i:2}/{len(test_cases)}] {start:20}{target:20} : ERROR [{result.error_type}] {result.error[:50]}")
else:
status = "WIN" if result.won else "LOST"
clicks = result.clicks if result.won else f">{args.max_steps}"
print(f" [{i:2}/{len(test_cases)}] {start:20}{target:20} : {status} ({clicks} clicks, {result.time_seconds:.1f}s)")
# ==========================================================================
# LLM AGENTS
# ==========================================================================
print("\n" + "-" * 80)
print("TESTING LLM AGENTS")
print("-" * 80)
if args.free_only:
llm_models = FREE_MODELS
else:
# Skip free models by default (rate limits make them unreliable)
llm_models = BUDGET_MODELS + FAST_MODELS + PREMIUM_MODELS
for model in llm_models:
model_short = model.split("/")[-1]
# Agent name uses short model name (after slash), truncated to 25 chars
if len(model_short) > 25:
agent_name_for_cache = f"llm-{model_short[:22]}..."
else:
agent_name_for_cache = f"llm-{model_short}"
print(f"\n{model_short[:30]}:")
for i, (start, target) in enumerate(test_cases, 1):
# Skip if already completed (check both possible agent name formats)
if (agent_name_for_cache, start, target) in completed_games:
print(f" [{i:2}/{len(test_cases)}] {start:20}{target:20} : CACHED")
continue
result = run_game("llm", start, target, args.max_steps, model=model)
all_results.append(result)
append_result(jsonl_path, result) # Incremental save
if result.error:
print(f" [{i:2}/{len(test_cases)}] {start:20}{target:20} : ERROR [{result.error_type}] {result.error[:50]}")
else:
status = "WIN" if result.won else "LOST"
clicks = result.clicks if result.won else f">{args.max_steps}"
print(f" [{i:2}/{len(test_cases)}] {start:20}{target:20} : {status} ({clicks} clicks, {result.time_seconds:.1f}s)")
# Small delay between LLM calls to avoid rate limits
time.sleep(0.5)
# ==========================================================================
# SUMMARY
# ==========================================================================
print("\n" + "=" * 80)
print("SUMMARY")
print("=" * 80)
# Get unique agent names
agent_names = sorted(set(r.agent for r in all_results))
summaries = []
for agent_name in agent_names:
summary = summarize_results(all_results, agent_name)
summaries.append(summary)
# Sort by win rate, then by avg clicks
summaries.sort(key=lambda s: (-s.win_rate, s.avg_clicks_when_won))
print(f"\n{'Agent':<45} {'Wins':>8} {'Win%':>8} {'AvgClicks':>10} {'AvgTime':>10}")
print("-" * 85)
for s in summaries:
win_pct = f"{s.win_rate*100:.0f}%"
avg_clicks = f"{s.avg_clicks_when_won:.1f}" if s.avg_clicks_when_won > 0 else "-"
avg_time = f"{s.avg_time_seconds:.1f}s"
print(f"{s.agent:<45} {s.wins:>3}/{s.games_played:<3} {win_pct:>8} {avg_clicks:>10} {avg_time:>10}")
# ==========================================================================
# TOP 5 LLM RECOMMENDATIONS
# ==========================================================================
print("\n" + "=" * 80)
print("TOP 5 LLM RECOMMENDATIONS")
print("=" * 80)
llm_summaries = [s for s in summaries if "llm-" in s.agent]
llm_summaries.sort(key=lambda s: (-s.win_rate, s.avg_clicks_when_won, s.avg_time_seconds))
for i, s in enumerate(llm_summaries[:5], 1):
model_name = s.agent.replace("llm-", "")
print(f"{i}. {model_name}")
print(f" Win rate: {s.win_rate*100:.0f}%, Avg clicks: {s.avg_clicks_when_won:.1f}, Avg time: {s.avg_time_seconds:.1f}s")
# ==========================================================================
# ERROR SUMMARY
# ==========================================================================
errors = [r for r in all_results if r.error]
if errors:
print("\n" + "=" * 80)
print("ERROR SUMMARY")
print("=" * 80)
# Count errors by type
error_counts: dict[str, int] = {}
for r in errors:
error_counts[r.error_type] = error_counts.get(r.error_type, 0) + 1
for error_type, count in sorted(error_counts.items(), key=lambda x: -x[1]):
print(f" {error_type}: {count} errors")
print(f"\nTotal errors: {len(errors)} / {len(all_results)} games")
# ==========================================================================
# SAVE FINAL RESULTS (consolidate from JSONL)
# ==========================================================================
# Compute error summary for JSON
error_summary = {}
for r in all_results:
if r.error:
error_summary[r.error_type] = error_summary.get(r.error_type, 0) + 1
output_data = {
"test_cases": [{"start": s, "target": t} for s, t in test_cases],
"results": [asdict(r) for r in all_results],
"summaries": [asdict(s) for s in summaries],
"error_summary": error_summary,
"total_errors": len(errors) if errors else 0,
}
with open(output_path, "w", encoding="utf-8") as f:
json.dump(output_data, f, indent=2)
print(f"\nResults saved to {output_path}")
print(f"Checkpoint file: {jsonl_path}")
return 0
if __name__ == "__main__":
sys.exit(main())