#!/usr/bin/env bash # # Benchmark: fff MCP vs Claude Code native tools on real search tasks # # Usage: # ./scripts/benchmark-claude.sh [concept_number] [--fff-only | --native-only] # # Runs real Claude Code instances against ~/dev/lightsource: # - With fff MCP tools (frecency-ranked, fuzzy search) # - With native tools only (Glob, Grep, Read) # Then compares: tokens, cost, turns, and whether the right file was found. # # Requirements: # - claude CLI in PATH # - ~/dev/lightsource exists # - fff MCP server built (cargo build --release, binary at target/release/fff-mcp) # # Auth: The script inherits YOUR shell environment. If you use AWS Bedrock, # make sure your AWS credentials are exported before running. # Run `claude --print -p "hello"` first to verify auth works. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" LIGHTSOURCE="$HOME/dev/lightsource" RESULTS_DIR="$SCRIPT_DIR/benchmark-results" MAX_TURNS=10 TIMEOUT_SEC=300 # 5 min per concept per mode MODEL="us.anthropic.claude-opus-4-6-v1" mkdir -p "$RESULTS_DIR" # Write MCP config to temp file to avoid shell quoting issues. # Both modes (fff and native) connect the fff MCP so context overhead is identical. FFF_MCP_FILE=$(mktemp) trap "rm -f $FFF_MCP_FILE" EXIT cat > "$FFF_MCP_FILE" </dev/null; then echo "ERROR: claude CLI not found in PATH" exit 1 fi if [[ ! -d "$LIGHTSOURCE" ]]; then echo "ERROR: $LIGHTSOURCE does not exist" exit 1 fi # Quick auth test — must clear nesting env vars, cd to lightsource, use &1 || true) if [[ -z "$AUTH_TEST" ]] || echo "$AUTH_TEST" | grep -q '"is_error":true' 2>/dev/null; then echo "ERROR: Claude auth failed. Test output:" echo "$AUTH_TEST" | head -5 echo "" echo "If using AWS Bedrock, make sure your AWS credentials are exported:" echo " export AWS_ACCESS_KEY_ID=..." echo " export AWS_SECRET_ACCESS_KEY=..." echo " export AWS_SESSION_TOKEN=..." echo "" echo "Or run: aws sso login" exit 1 fi echo " Auth OK" echo "" # ─── 10 SEARCH CONCEPTS ─────────────────────────────────────────────────────── declare -a PROMPTS declare -a TARGETS declare -a NAMES NAMES[1]="fuzzy-function-search" PROMPTS[1]="Find the function that loads metadata for an InProgressQuote in the lightsource codebase. Show me the function signature and which file it's in." TARGETS[1]="quotes/storage/db/src/model/quote.rs" NAMES[2]="api-endpoint-discovery" PROMPTS[2]="Find the GraphQL mutation that handles user file uploads (the prepare upload step). Show me the function and its file path." TARGETS[2]="user_files_service/graphql/src/mutation.rs" NAMES[3]="cross-service-config" PROMPTS[3]="Find where QuotesServiceClient is defined as a struct and how it's constructed. Show me the struct definition and its file." TARGETS[3]="quotes_service_client" NAMES[4]="test-file-discovery" PROMPTS[4]="Find the test file for virtual expression manifests in the quotes engine. Show me the file path and list what tests are in it." TARGETS[4]="virtual_expression_manifest_test" NAMES[5]="error-type-definition" PROMPTS[5]="Find where the custom Error type with variants like not_found and permission_denied is defined in the common/error crate. Show me the enum or struct definition." TARGETS[5]="common/error" NAMES[6]="database-model-search" PROMPTS[6]="Find the Diesel ORM model struct for InProgressQuote — the actual struct definition with its derives, not usages. Show me the struct and its file path." TARGETS[6]="quotes/storage/db/src/model/quote.rs" NAMES[7]="auth-flow-tracing" PROMPTS[7]="Find where ActorAuth is defined and trace how it's used in service GraphQL contexts. Show me the definition and one example of it being extracted in a resolver." TARGETS[7]="actor_auth" NAMES[8]="todo-tech-debt" PROMPTS[8]="Find TODO comments tagged with github issues numbers (like #... or similar patterns) in the quotes-related code. Show me a few examples with their file paths." TARGETS[8]="TODO" NAMES[9]="cross-language-pattern" PROMPTS[9]="Find code related to QuoteBuilder across both Rust backend and TypeScript frontend. Show me one example from each language." TARGETS[9]="QuoteBuilder" NAMES[10]="broad-pattern-search" PROMPTS[10]="Find the main GraphQL query resolvers for sourcing projects — specifically the resolver that loads a single sourcing project by ID. Show me the resolver function and file." TARGETS[10]="sourcing_project" NAMES[11]="file-by-name-lookup" PROMPTS[11]="What files exist in this repository related to 'quote_builder'? List 10 paths from frontend and 10 from backend." TARGETS[11]="quote_builder" # NOTE: Concept 11 tests file-by-name lookup. The model strongly prefers native Glob # over find_files due to Claude Code's system prompt. find_files would be faster here # (fuzzy: 'tsconfig sourcing' → 1 call) but the model won't use it unprompted. # ─── HELPER FUNCTIONS ────────────────────────────────────────────────────────── millis() { python3 -c 'import time; print(int(time.time()*1000))' } run_claude() { local mode="$1" # "fff" or "native" local concept="$2" local raw_prompt="${PROMPTS[$concept]}" local outfile="$RESULTS_DIR/${NAMES[$concept]}-${mode}.json" # Both modes connect fff MCP so context overhead is identical. # The prompt prefix steers which tools Claude actually uses. local mcp_args=(--mcp-config "$FFF_MCP_FILE" --strict-mcp-config) # (tool_args removed — both modes use identical MCP config, prompt steers tool choice) local reasoning_instruction="IMPORTANT: Before EVERY tool call, write 1-2 sentences explaining your reasoning: why you chose this specific tool, what query/pattern you picked and why, what you expect to find, and if this is a follow-up, what the previous result told you that led to this next step." local prompt if [[ "$mode" == "fff" ]]; then prompt="Use fff tools (grep, find_files, multi_grep) instead of native Glob/Grep. $reasoning_instruction $raw_prompt" else prompt="IMPORTANT: For file search and content search, use ONLY the native tools (Glob, Grep, Read). Do NOT use any mcp__fff__* tools. Ignore the fff MCP server entirely. $reasoning_instruction $raw_prompt" fi local model_args=() if [[ -n "$MODEL" ]]; then model_args=(--model "$MODEL") fi local errfile="$RESULTS_DIR/${NAMES[$concept]}-${mode}.stderr" local streamfile="$RESULTS_DIR/${NAMES[$concept]}-${mode}.stream.jsonl" echo " Running [$mode] concept $concept: ${NAMES[$concept]} (timeout ${TIMEOUT_SEC}s)..." local start_time start_time=$(millis) # Capture stream-json for per-turn analysis, then extract the final result. # IMPORTANT: "$streamfile" 2>"$errfile" ) || { local exit_code=$? if [[ $exit_code -eq 124 ]]; then echo " TIMEOUT after ${TIMEOUT_SEC}s" echo "{\"type\":\"result\",\"is_error\":true,\"result\":\"TIMEOUT after ${TIMEOUT_SEC}s\",\"num_turns\":0,\"total_cost_usd\":0,\"duration_ms\":0,\"usage\":{\"input_tokens\":0,\"output_tokens\":0}}" > "$outfile" return elif [[ ! -s "$streamfile" ]]; then echo " FAILED (exit $exit_code)" local stderr_msg stderr_msg=$(head -3 "$errfile" 2>/dev/null | tr '\n' ' ') echo "{\"type\":\"result\",\"is_error\":true,\"result\":\"Process failed (exit $exit_code): $stderr_msg\",\"num_turns\":0,\"total_cost_usd\":0,\"duration_ms\":0,\"usage\":{\"input_tokens\":0,\"output_tokens\":0}}" > "$outfile" fi } # Print stderr if non-empty (helps debugging) if [[ -s "$errfile" ]]; then echo " stderr: $(head -1 "$errfile")" fi # Extract final result JSON from stream (last line with type=result) if [[ -s "$streamfile" ]]; then grep '"type":"result"' "$streamfile" | tail -1 > "$outfile" 2>/dev/null || true fi local end_time end_time=$(millis) local wall_ms=$(( end_time - start_time )) # Inject wall time into the JSON if [[ -f "$outfile" ]] && [[ -s "$outfile" ]]; then local tmp tmp=$(mktemp) jq --argjson wall "$wall_ms" '. + {wall_ms: $wall}' "$outfile" > "$tmp" 2>/dev/null && mv "$tmp" "$outfile" || rm -f "$tmp" fi # Quick status line local cost turns found_str cost_fmt cost=$(jq -r '.total_cost_usd // 0' "$outfile" 2>/dev/null || echo "0") cost_fmt=$(printf '%.4f' "$cost" 2>/dev/null || echo "$cost") turns=$(jq -r '.num_turns // 0' "$outfile" 2>/dev/null || echo "?") local err=$(jq -r '.is_error // false' "$outfile" 2>/dev/null || echo "?") if [[ "$err" == "true" ]]; then found_str="ERROR" else found_str="ok" fi echo " Done in $((wall_ms/1000))s | \$$cost_fmt | ${turns} turns | $found_str" # ── Per-turn tool call analysis ── if [[ -s "$streamfile" ]]; then echo "" echo " ┌─ Tool call trace [$mode] ─────────────────────────────────────────" # Extract tool_use events and tool_result sizes from stream python3 - "$streamfile" <<'PYEOF' import json, sys, textwrap stream_file = sys.argv[1] # Ordered list of events: ("text", text) | ("tool", name, summary, id) | ("result", id, size) events = [] result_sizes = {} # tool_use_id -> content_length with open(stream_file) as f: for line in f: line = line.strip() if not line: continue try: msg = json.loads(line) except (ValueError, json.JSONDecodeError): continue msg_type = msg.get("type", "") content_blocks = [] if msg_type == "assistant" and "message" in msg: content_blocks = msg["message"].get("content", []) elif msg_type == "user" and "message" in msg: content_blocks = msg["message"].get("content", []) for block in content_blocks: if not isinstance(block, dict): continue # Assistant reasoning text if block.get("type") == "text" and msg_type == "assistant": text = block.get("text", "").strip() if text: events.append(("text", text)) # Tool use (Claude calling a tool) if block.get("type") == "tool_use": name = block.get("name", "?") inp = block.get("input", {}) if "query" in inp: summary = f'query="{inp["query"]}"' elif "pattern" in inp: summary = f'pattern="{inp["pattern"]}"' elif "patterns" in inp: summary = f'patterns={json.dumps(inp["patterns"])}' elif "file_path" in inp: summary = f'file="{inp["file_path"][-60:]}"' elif "path" in inp: summary = f'path="{inp["path"][-60:]}"' elif "command" in inp: summary = f'cmd="{inp["command"][:70]}"' else: summary = str(inp)[:70] events.append(("tool", name, summary, block.get("id", ""))) # Tool result (response back from tool) if block.get("type") == "tool_result": tid = block.get("tool_use_id", "") content = block.get("content", "") if isinstance(content, list): total_len = sum(len(c.get("text", "")) for c in content if isinstance(c, dict)) elif isinstance(content, str): total_len = len(content) else: total_len = len(str(content)) result_sizes[tid] = total_len # Print trace with reasoning tool_num = 0 last_was_text = False for event in events: if event[0] == "text": text = event[1] # Truncate long reasoning, skip final answer blocks (contain code fences) if "```" in text: # Final answer with code — just show first line first_line = text.split("\n")[0].strip() if first_line: text = first_line[:120] + ("..." if len(first_line) > 120 else "") else: continue elif len(text) > 300: text = text[:297] + "..." wrapped = textwrap.wrap(text, width=90) if not last_was_text: print(" |") for wline in wrapped: print(f" | 💭 {wline}") last_was_text = True elif event[0] == "tool": _, name, summary, tid = event tool_num += 1 rsize = result_sizes.get(tid, -1) size_str = f" -> {rsize:,} chars" if rsize >= 0 else "" print(f" | {tool_num:2d}. {name:25s} {summary[:50]:50s}{size_str}") last_was_text = False if tool_num == 0: print(" | (no tool calls captured)") print(f" |") print(f" | Total: {tool_num} tool calls") PYEOF echo " └──────────────────────────────────────────────────────────────────" echo "" fi } parse_result() { local jsonfile="$1" local target="$2" if [[ ! -f "$jsonfile" ]] || [[ ! -s "$jsonfile" ]]; then echo "0|0|0|0|false|false" return fi local cost_usd num_turns duration_ms wall_ms is_error result cost_usd=$(jq -r '.total_cost_usd // 0' "$jsonfile" 2>/dev/null || echo "0") num_turns=$(jq -r '.num_turns // 0' "$jsonfile" 2>/dev/null || echo "0") duration_ms=$(jq -r '.duration_ms // 0' "$jsonfile" 2>/dev/null || echo "0") wall_ms=$(jq -r '.wall_ms // 0' "$jsonfile" 2>/dev/null || echo "0") is_error=$(jq -r '.is_error // false' "$jsonfile" 2>/dev/null || echo "false") result=$(jq -r '.result // ""' "$jsonfile" 2>/dev/null || echo "") local found="false" if echo "$result" | grep -qi "$target" 2>/dev/null; then found="true" fi echo "${cost_usd}|${num_turns}|${duration_ms}|${wall_ms}|${is_error}|${found}" } print_comparison() { local concept="$1" local name="${NAMES[$concept]}" local target="${TARGETS[$concept]}" local fff_file="$RESULTS_DIR/${name}-fff.json" local native_file="$RESULTS_DIR/${name}-native.json" local fff_data native_data fff_data=$(parse_result "$fff_file" "$target") native_data=$(parse_result "$native_file" "$target") IFS='|' read -r fff_cost fff_turns fff_dur fff_wall fff_err fff_found <<< "$fff_data" IFS='|' read -r nat_cost nat_turns nat_dur nat_wall nat_err nat_found <<< "$native_data" # Token counts from usage local fff_input fff_output nat_input nat_output fff_input=$(jq -r '.usage.input_tokens // 0' "$fff_file" 2>/dev/null || echo "0") fff_output=$(jq -r '.usage.output_tokens // 0' "$fff_file" 2>/dev/null || echo "0") nat_input=$(jq -r '.usage.input_tokens // 0' "$native_file" 2>/dev/null || echo "0") nat_output=$(jq -r '.usage.output_tokens // 0' "$native_file" 2>/dev/null || echo "0") local fff_tokens=$((fff_input + fff_output)) local nat_tokens=$((nat_input + nat_output)) # Determine winner local winner="tie" if [[ "$fff_found" == "true" && "$nat_found" == "false" ]]; then winner="FFF" elif [[ "$fff_found" == "false" && "$nat_found" == "true" ]]; then winner="NATIVE" elif [[ "$fff_found" == "true" && "$nat_found" == "true" ]]; then # Both found — compare cost with 15% tolerance band for ties local ratio ratio=$(echo "scale=4; $fff_cost / $nat_cost" | bc 2>/dev/null || echo "1") # ratio < 0.85 means FFF is >15% cheaper → FFF wins # ratio > 1.15 means FFF is >15% more expensive → NATIVE wins # otherwise → tie local ratio_x100 ratio_x100=$(echo "$ratio * 100" | bc 2>/dev/null | cut -d. -f1 || echo "100") if [[ "${ratio_x100:-100}" -lt 85 ]]; then winner="FFF" elif [[ "${ratio_x100:-100}" -gt 115 ]]; then winner="NATIVE" fi fi # Format costs to 4 decimal places local fff_cost_fmt nat_cost_fmt fff_cost_fmt=$(printf '%.4f' "$fff_cost" 2>/dev/null || echo "$fff_cost") nat_cost_fmt=$(printf '%.4f' "$nat_cost" 2>/dev/null || echo "$nat_cost") echo "" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo " CONCEPT $concept: $name" echo " Target: $target" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" printf " %-12s │ %10s │ %6s │ %8s │ %8s │ %7s │ %7s\n" "" "Cost" "Turns" "Tokens" "Wall (s)" "Found?" "Error?" echo " ─────────────┼────────────┼────────┼──────────┼──────────┼─────────┼────────" printf " %-12s │ %10s │ %6s │ %8s │ %8s │ %7s │ %7s\n" \ "fff MCP" "\$$fff_cost_fmt" "$fff_turns" "$fff_tokens" "$((fff_wall/1000))" "$fff_found" "$fff_err" printf " %-12s │ %10s │ %6s │ %8s │ %8s │ %7s │ %7s\n" \ "Native" "\$$nat_cost_fmt" "$nat_turns" "$nat_tokens" "$((nat_wall/1000))" "$nat_found" "$nat_err" echo " ─────────────┴────────────┴────────┴──────────┴──────────┴─────────┴────────" # Cost savings percentage if [[ "$nat_cost" != "0" ]]; then local cost_savings cost_savings=$(echo "scale=1; (1 - $fff_cost / $nat_cost) * 100" | bc 2>/dev/null || echo "?") echo " Cost savings: ${cost_savings}% (fff: \$$fff_cost_fmt, native: \$$nat_cost_fmt)" fi echo " WINNER: $winner" echo "" } # ─── MAIN ────────────────────────────────────────────────────────────────────── SELECTED="" MODE="both" # both, fff-only, native-only while [[ $# -gt 0 ]]; do case "$1" in --fff-only) MODE="fff"; shift ;; --native-only) MODE="native"; shift ;; --model) MODEL="$2"; shift 2 ;; --max-turns) MAX_TURNS="$2"; shift 2 ;; --timeout) TIMEOUT_SEC="$2"; shift 2 ;; [0-9]*) SELECTED="$1"; shift ;; *) echo "Usage: $0 [1-10] [options]" echo "" echo "Options:" echo " --fff-only Only run fff MCP (skip native)" echo " --native-only Only run native tools (skip fff)" echo " --model MODEL Use specific model (e.g., haiku, sonnet)" echo " --max-turns N Max agentic turns per run (default: 10)" echo " --timeout SEC Timeout per run in seconds (default: 300)" exit 1 ;; esac done echo "╔════════════════════════════════════════════════════════════════════════════╗" echo " Target: ~/dev/lightsource (194K files) " echo " Max turns: $MAX_TURNS | Timeout: ${TIMEOUT_SEC}s | Budget: \$0.50/run" echo "╚════════════════════════════════════════════════════════════════════════════╝" echo "" if [[ -n "$SELECTED" ]]; then concepts=("$SELECTED") else concepts=(1 2 3 4 5 6 7 8 9 10 11) fi for c in "${concepts[@]}"; do echo "── Concept $c: ${NAMES[$c]} ──" if [[ "$MODE" == "both" || "$MODE" == "fff" ]]; then run_claude "fff" "$c" fi if [[ "$MODE" == "both" || "$MODE" == "native" ]]; then run_claude "native" "$c" fi if [[ "$MODE" == "both" ]]; then print_comparison "$c" fi done # ─── FINAL ANALYSIS ────────────────────────────────────────────────────────── if [[ "$MODE" == "both" && ${#concepts[@]} -ge 3 ]]; then echo "" echo "╔════════════════════════════════════════════════════════════════════════════╗" echo " ANALYSIS " echo "╚════════════════════════════════════════════════════════════════════════════╝" python3 - "$RESULTS_DIR" "${concepts[*]}" <<'ANALYSIS_EOF' import json, os, sys from pathlib import Path results_dir = sys.argv[1] concepts = [int(x) for x in sys.argv[2].split()] NAMES = { 1: "fuzzy-function-search", 2: "api-endpoint-discovery", 3: "cross-service-config", 4: "test-file-discovery", 5: "error-type-definition", 6: "database-model-search", 7: "auth-flow-tracing", 8: "todo-tech-debt", 9: "cross-language-pattern", 10: "broad-pattern-search", 11: "file-by-name-lookup", } def load_result(name, mode): path = os.path.join(results_dir, f"{name}-{mode}.json") if not os.path.exists(path): return None try: with open(path) as f: return json.load(f) except: return None def load_traces(name, mode): """Extract tool calls with their input and result sizes from stream file.""" path = os.path.join(results_dir, f"{name}-{mode}.stream.jsonl") if not os.path.exists(path): return [] tool_calls = [] result_sizes = {} with open(path) as f: for line in f: line = line.strip() if not line: continue try: msg = json.loads(line) except: continue content_blocks = [] msg_type = msg.get("type", "") if msg_type == "assistant" and "message" in msg: content_blocks = msg["message"].get("content", []) elif msg_type == "user" and "message" in msg: content_blocks = msg["message"].get("content", []) for block in content_blocks: if not isinstance(block, dict): continue if block.get("type") == "tool_use": inp = block.get("input", {}) tool_calls.append({ "name": block.get("name", "?"), "id": block.get("id", ""), "input": inp, "query": inp.get("query", inp.get("pattern", inp.get("patterns", inp.get("file_path", "")))), }) if block.get("type") == "tool_result": tid = block.get("tool_use_id", "") content = block.get("content", "") if isinstance(content, list): total = sum(len(c.get("text", "")) for c in content if isinstance(c, dict)) elif isinstance(content, str): total = len(content) else: total = len(str(content)) result_sizes[tid] = total for tc in tool_calls: tc["result_chars"] = result_sizes.get(tc["id"], -1) return tool_calls # ── Collect all data ── rows = [] total_fff = 0 total_nat = 0 fff_wins = 0 nat_wins = 0 ties = 0 for c in concepts: name = NAMES.get(c, f"concept-{c}") fff = load_result(name, "fff") nat = load_result(name, "native") if not fff or not nat: continue fc = fff.get("total_cost_usd", 0) nc = nat.get("total_cost_usd", 0) ft = fff.get("num_turns", 0) nt = nat.get("num_turns", 0) fw = fff.get("wall_ms", 0) / 1000 nw = nat.get("wall_ms", 0) / 1000 total_fff += fc total_nat += nc if nc > 0: ratio = fc / nc else: ratio = 1.0 if ratio < 0.85: winner = "FFF" fff_wins += 1 elif ratio > 1.15: winner = "NATIVE" nat_wins += 1 else: winner = "TIE" ties += 1 fff_traces = load_traces(name, "fff") nat_traces = load_traces(name, "native") rows.append({ "num": c, "name": name, "fff_cost": fc, "nat_cost": nc, "fff_turns": ft, "nat_turns": nt, "fff_wall": fw, "nat_wall": nw, "winner": winner, "ratio": ratio, "fff_traces": fff_traces, "nat_traces": nat_traces, }) # ── Summary table ── print() print(f" {'#':>2} {'Concept':<28} {'FFF $':>8} {'Nat $':>8} {'Δ':>6} {'FFF T':>5} {'Nat T':>5} {'Winner':>8}") print(f" {'─'*2} {'─'*28} {'─'*8} {'─'*8} {'─'*6} {'─'*5} {'─'*5} {'─'*8}") for r in rows: savings = (1 - r["ratio"]) * 100 print(f" {r['num']:>2} {r['name']:<28} ${r['fff_cost']:.4f} ${r['nat_cost']:.4f} {savings:>+5.0f}% {r['fff_turns']:>5} {r['nat_turns']:>5} {r['winner']:>8}") if total_nat > 0: overall = (1 - total_fff / total_nat) * 100 else: overall = 0 print() print(f" Score: FFF {fff_wins} | Native {nat_wins} | Tie {ties}") print(f" Total: FFF ${total_fff:.4f} | Native ${total_nat:.4f} | Savings: {overall:+.1f}%") print() # ── Waste pattern analysis ── print(" ┌─ WASTE ANALYSIS ────────────────────────────────────────────────────") for r in rows: traces = r["fff_traces"] if not traces: continue issues = [] # Count tool types tool_search_calls = [t for t in traces if t["name"] == "ToolSearch"] read_calls = [t for t in traces if t["name"] == "Read"] grep_calls = [t for t in traces if "grep" in t["name"].lower()] find_calls = [t for t in traces if "find" in t["name"].lower()] # Issue: ToolSearch overhead (each costs ~a turn) if len(tool_search_calls) >= 2: issues.append(f"{len(tool_search_calls)} ToolSearch calls (model loading tools in multiple turns)") # Issue: Read after grep (grep didn't give enough context) if read_calls and grep_calls: read_files = set() for rc in read_calls: fp = rc.get("input", {}).get("file_path", "") if fp: read_files.add(fp.split("/")[-1]) issues.append(f"Read calls after grep ({', '.join(read_files)}) — grep output wasn't sufficient") # Issue: Many grep calls with tiny results (model is probing) tiny_greps = [t for t in grep_calls if 0 <= t["result_chars"] <= 50] if len(tiny_greps) >= 2: queries = [str(t["query"])[:40] for t in tiny_greps] issues.append(f"{len(tiny_greps)} greps returned ≤50 chars: {queries}") # Issue: Large result from Read (could have been avoided) for rc in read_calls: if rc["result_chars"] > 5000: fn = rc.get("input", {}).get("file_path", "?").split("/")[-1] issues.append(f"Read({fn}) returned {rc['result_chars']:,} chars — expensive") # Issue: grep returned huge result (over-broad query) for gc in grep_calls: if gc["result_chars"] > 3000: q = str(gc["query"])[:40] issues.append(f"Grep({q}) returned {gc['result_chars']:,} chars — too broad") # Issue: Sequential greps that could have been multi_grep if len(grep_calls) >= 3 and not any("multi" in t["name"].lower() for t in traces): issues.append(f"{len(grep_calls)} sequential greps — could multi_grep reduce to 1 call?") if issues and r["winner"] != "FFF": print(f" │") savings = (1 - r["ratio"]) * 100 print(f" │ #{r['num']} {r['name']} ({r['winner']}, {savings:+.0f}%)") for issue in issues: print(f" │ • {issue}") # Show fff trace summary trace_summary = " → ".join( t["name"].replace("mcp__fff__", "").replace("ToolSearch", "🔍") for t in traces ) print(f" │ trace: {trace_summary}") # Show native trace for comparison nat_traces = r["nat_traces"] if nat_traces: nat_summary = " → ".join( t["name"].replace("ToolSearch", "🔍") for t in nat_traces ) print(f" │ native: {nat_summary}") print(" │") print(" └────────────────────────────────────────────────────────────────────") print() # ── Actionable suggestions ── print(" ┌─ SUGGESTED IMPROVEMENTS ────────────────────────────────────────────") # Aggregate patterns across all concepts total_tool_search = sum(len([t for t in r["fff_traces"] if t["name"] == "ToolSearch"]) for r in rows) total_reads_after_grep = sum( 1 for r in rows if any("grep" in t["name"].lower() for t in r["fff_traces"]) and any(t["name"] == "Read" for t in r["fff_traces"]) ) total_tiny_greps = sum( len([t for t in r["fff_traces"] if "grep" in t["name"].lower() and 0 <= t["result_chars"] <= 50]) for r in rows ) total_sequential_greps = sum( len([t for t in r["fff_traces"] if "grep" in t["name"].lower()]) for r in rows if len([t for t in r["fff_traces"] if "grep" in t["name"].lower()]) >= 3 ) if total_reads_after_grep >= 3: print(f" │ 1. EXPAND GREP CONTEXT: {total_reads_after_grep}/{len(rows)} concepts do Read after grep.") print(f" │ Grep results need more inline context to avoid follow-up Reads.") print(f" │ → Increase MAX_DEF_EXPAND, show more body for non-def matches") print(f" │") if total_tiny_greps >= 5: print(f" │ 2. IMPROVE ZERO/LOW-RESULT GUIDANCE: {total_tiny_greps} greps returned ≤50 chars.") print(f" │ When results are sparse, show related files/symbols to help the model.") print(f" │ → Add 'did you mean?' suggestions or sibling files in same directory") print(f" │") if total_tool_search >= len(rows) * 1.5: print(f" │ 3. REDUCE TOOLSEARCH OVERHEAD: {total_tool_search} ToolSearch calls across {len(rows)} concepts.") print(f" │ Each ToolSearch costs a turn. Model loads tools incrementally.") print(f" │ → Can't fix directly, but reducing total calls makes this less impactful") print(f" │") if total_sequential_greps >= 8: print(f" │ 4. PROMOTE MULTI_GREP: {total_sequential_greps} sequential grep calls could be batched.") print(f" │ Model uses sequential grep when multi_grep would be more efficient.") print(f" │ → Improve multi_grep description or auto-suggest in 0-result messages") print(f" │") # Concept-specific suggestions losing = [r for r in rows if r["winner"] == "NATIVE"] if losing: print(f" │ LOSING CONCEPTS ({len(losing)}):") for r in losing: traces = r["fff_traces"] nat_traces = r["nat_traces"] fff_grep_count = len([t for t in traces if "grep" in t["name"].lower()]) nat_grep_count = len([t for t in nat_traces if "grep" in t["name"].lower() or t["name"] == "Grep"]) fff_read_count = len([t for t in traces if t["name"] == "Read"]) nat_read_count = len([t for t in nat_traces if t["name"] == "Read"]) savings = (1 - r["ratio"]) * 100 print(f" │ #{r['num']} {r['name']} ({savings:+.0f}%): fff={fff_grep_count}grep+{fff_read_count}read vs nat={nat_grep_count}grep+{nat_read_count}read") print(" │") print(" └────────────────────────────────────────────────────────────────────") print() ANALYSIS_EOF fi