GitNexus/eval/analysis/analyze_results.py
John R. Eakin c68d7975e6
docs: agent development framework, GitHub templates, eval refactor (#479)
* ci: E2E workflow, web typecheck job, pre-commit hook, test suite

CI:
- ci.yml consolidated to reference ci-tests.yml
- ci-quality.yml: add typecheck-web job for gitnexus-web/
- ci-e2e.yml: E2E workflow with dorny/paths-filter (web changes only)
- ci-report.yml: remove dead integration-reports references
- CI gate allows skipped E2E status
- .gitignore: playwright artifacts, eval test artifacts

Pre-commit hook:
- .githooks/pre-commit: typecheck + unit tests for both packages
- Activated via git config core.hooksPath in prepare script

Test infrastructure:
- Vitest + React Testing Library: 58 unit tests
  (graph, server-connection, mermaid, settings, constants, utils, paths)
- Playwright E2E: 5 tests + manual recording harness
- vitest.config from vitest/config, engines.node >= 20
- Playwright artifacts retain-on-failure
- wait-on in devDependencies
- vitest/coverage-v8 aligned with vitest 4.x

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update gitnexus-web package-lock.json

Reflects devDependency additions (vitest, playwright, wait-on,
@testing-library, etc.) from package.json changes in this PR.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(e2e): add missing process-list-loaded testid, increase CI timeouts

- Add data-testid="process-list-loaded" to ProcessesPanel (E2E tests
  were waiting for an element that didn't exist)
- Increase server connect timeouts from 5s to 10s for slower CI

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(ci): run gitnexus-web unit tests in CI, remove unused variable

- Add gitnexus-web npm ci + vitest run to ci-tests.yml so web unit
  tests are gated by the CI status check (were only running locally)
- Remove unused IS_PLAYWRIGHT_AUTOMATION variable from E2E spec

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(e2e): add process-row testid, wait for networkidle on page load

- Add data-testid="process-row" to ProcessItem component (E2E tests
  referenced it but it didn't exist in the source)
- Use waitUntil: 'networkidle' on page.goto to ensure Vite dev server
  is fully ready before interacting (fixes first-test timeout in CI)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(e2e): add process-view-button and process-highlight-button testids

E2E tests referenced these data-testid attributes but they didn't
exist in ProcessItem. All 6 E2E testids now have matching source
elements: status-ready, process-list-loaded, process-row,
process-view-button, process-highlight-button, server-url-input.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(e2e): remove networkidle — Vite HMR WebSocket prevents it from resolving

networkidle waits for zero network activity for 500ms, but Vite's HMR
WebSocket stays open permanently, causing page.goto to timeout at 60s
on all tests after the first. The explicit toBeVisible waits on UI
elements are sufficient and deterministic.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(e2e): wait for Server button visibility, add CI retry, all 5 tests pass locally

Root cause: test 1 clicked the Server button before React hydrated,
so the tab content never rendered and the input wasn't found.

Fixes:
- Wait for Server button toBeVisible before clicking
- Increase input wait to 15s
- Remove networkidle (Vite HMR WebSocket prevents it from resolving)
- Add retries: 1 in CI for transient cold-start flakiness

Verified locally: all 5 E2E tests pass, 198 unit tests pass, typecheck clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(ci): tolerate LadybugDB native crash during analyze step

gitnexus analyze can crash with "double free or corruption" (known
issue #273) during the LadybugDB native addon shutdown. The index is
usually written successfully before the crash. The workflow now:
1. Allows analyze to exit non-zero with a warning
2. Verifies .gitnexus index was actually created
3. Only fails if no index exists (real failure)

All tests verified locally: 198 unit, 5 E2E pass, typecheck clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(ci): fix shell quoting in analyze step, simplify to || true

The previous echo string had special characters that broke bash
quoting in GitHub Actions. Simplified to: analyze || true, then
check if .gitnexus exists.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: add agent development framework, GitHub templates, eval refactor

Agent framework (layered docs for AI-assisted contributions):
- AGENTS.md: canonical instructions, impact analysis, MCP tools
- CLAUDE.md: Claude Code-specific deltas and hooks
- GUARDRAILS.md: safety boundaries, non-negotiables, escalation
- ARCHITECTURE.md: monorepo layout, data flow map
- TESTING.md: test structure, commands, categories
- RUNBOOK.md: copy-paste operations for dev/CI/MCP
- llms.txt: minimal LLM context pointer

Editor integration:
- .cursor/index.mdc + rules/100-monorepo.mdc

GitHub templates:
- PR template with areas-touched checkboxes
- Bug report + feature request issue forms

Eval harness:
- Refactored mcp_bridge, tool_registry, constants
- Error sanitization utilities
- Property-based tests via Hypothesis

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(eval): use format_exception instead of format_exc in sanitize_exception

format_exc() returns the currently handled exception traceback, which
may be unrelated if called outside an active except block. Using
format_exception(type(exc), exc, exc.__traceback__) reliably captures
the passed exception's traceback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: update CONTRIBUTING.md and TESTING.md for current CI/hook setup

- CONTRIBUTING.md: add gitnexus-web typecheck command, pre-commit hook
  checklist item
- TESTING.md: add gitnexus-web typecheck command, pre-commit hook
  section (husky), update CI integration to list actual workflow files
  (ci-quality, ci-tests, ci-e2e)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: update testing docs to reflect CI/E2E changes from PR #486

- AGENTS.md: update test counts (CLI ~2000 unit, ~1850 integration),
  add gitnexus-web testing section (198 unit, 5 E2E with commands)
- RUNBOOK.md: fix Node requirement to >=20, fix E2E local repro command
- TESTING.md: E2E uses data-testid selectors + real servers, not mocks
- .cursor/rules/100-monorepo.mdc: add web test/E2E commands

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: address context engineering review — deduplicate tokens, expand Cursor rules

- Remove ~100-line gitnexus:start block from CLAUDE.md (was duplicated from AGENTS.md)
- Fix gitnexus:start block inlined inside AGENTS.md Reference Docs bullet (doubled)
- Replace CLAUDE.md scope table with pointer to AGENTS.md (single source of truth)
- Expand .cursor/index.mdc with 5 non-negotiable safety rules for always-on context
- Add .cursor/rules/200-eval.mdc with Python/eval commands (glob-scoped to eval/**)
- Improve llms.txt with priority annotations and descriptions
- Bump version headers to 1.2.0, last-reviewed to 2026-03-24

Saves ~1,400 tokens/session with zero information loss.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-03-25 06:48:41 +00:00

455 lines
16 KiB
Python

#!/usr/bin/env python3
"""
Results Analyzer for GitNexus SWE-bench Evaluation
Reads evaluation results and generates comparative analysis:
- Resolve rate by model x mode
- Cost comparison (total, per-instance)
- Token/API call efficiency
- GitNexus tool usage patterns
- Augmentation hit rates
Usage:
python -m analysis.analyze_results /path/to/results
python -m analysis.analyze_results /path/to/results --format markdown
python -m analysis.analyze_results /path/to/results --swebench-eval # run actual test verification
"""
import json
import logging
import os
import subprocess
import sys
from pathlib import Path
from typing import Any
import typer
from rich.console import Console
from rich.table import Table
from tool_registry import TOOL_METRIC_KEYS
logger = logging.getLogger("analyze_results")
console = Console()
app = typer.Typer(rich_markup_mode="rich", add_completion=False)
def load_run_results(results_dir: Path) -> dict[str, dict]:
"""
Load all run results from the results directory.
Returns: {run_id: {summary, preds, instances}}
"""
runs = {}
for run_dir in sorted(results_dir.iterdir()):
if not run_dir.is_dir():
continue
run_id = run_dir.name
run_data: dict[str, Any] = {"run_id": run_id, "dir": run_dir}
# Load summary
summary_path = run_dir / "summary.json"
if summary_path.exists():
run_data["summary"] = json.loads(summary_path.read_text())
# Load predictions
preds_path = run_dir / "preds.json"
if preds_path.exists():
run_data["preds"] = json.loads(preds_path.read_text())
# Load individual trajectories for detailed metrics
run_data["trajectories"] = {}
for traj_dir in run_dir.iterdir():
if not traj_dir.is_dir():
continue
for traj_file in traj_dir.glob("*.traj.json"):
try:
traj = json.loads(traj_file.read_text())
instance_id = traj.get("instance_id", traj_dir.name)
run_data["trajectories"][instance_id] = traj
except Exception:
pass
if run_data.get("preds") or run_data.get("summary"):
runs[run_id] = run_data
return runs
def parse_run_id(run_id: str) -> tuple[str, str]:
"""Parse 'model_mode' into (model, mode) using known suffixes."""
# Match the longest known suffix first to avoid hyphen collisions in model names.
known_modes = [
"native_augment",
"native",
"baseline",
"mcp",
"augment",
"full",
]
for mode in known_modes:
suffix = f"_{mode}"
if run_id.endswith(suffix):
return run_id[: -len(suffix)], mode
return run_id, "unknown"
def compute_metrics(run_data: dict) -> dict:
"""Compute evaluation metrics for a single run."""
preds = run_data.get("preds", {})
summary = run_data.get("summary", {})
trajectories = run_data.get("trajectories", {})
n_instances = len(preds)
n_with_patch = sum(1 for p in preds.values() if p.get("model_patch", "").strip())
# Cost and API call metrics from trajectories
costs = []
api_calls = []
gn_tool_calls = []
gn_augment_hits = []
gn_augment_calls = []
for instance_id, traj in trajectories.items():
info = traj.get("info", {})
model_stats = info.get("model_stats", {})
costs.append(model_stats.get("instance_cost", 0))
api_calls.append(model_stats.get("api_calls", 0))
gn = info.get("gitnexus", {}).get("metrics", {})
if gn:
gn_tool_calls.append(gn.get("total_tool_calls", 0))
gn_augment_hits.append(gn.get("augmentation_hits", 0))
gn_augment_calls.append(gn.get("augmentation_calls", 0))
# Also try summary-level metrics
if not costs and summary:
results = summary.get("results", [])
for r in results:
costs.append(r.get("cost", 0))
api_calls.append(r.get("n_calls", 0))
gn = r.get("gitnexus_metrics", {})
if gn:
gn_tool_calls.append(gn.get("total_tool_calls", 0))
gn_augment_hits.append(gn.get("augmentation_hits", 0))
gn_augment_calls.append(gn.get("augmentation_calls", 0))
total_cost = sum(costs)
total_calls = sum(api_calls)
return {
"n_instances": n_instances,
"n_with_patch": n_with_patch,
"patch_rate": n_with_patch / max(n_instances, 1),
"total_cost": total_cost,
"avg_cost": total_cost / max(n_instances, 1),
"total_api_calls": total_calls,
"avg_api_calls": total_calls / max(n_instances, 1),
"total_gn_tool_calls": sum(gn_tool_calls),
"avg_gn_tool_calls": sum(gn_tool_calls) / max(len(gn_tool_calls), 1) if gn_tool_calls else 0,
"total_augment_hits": sum(gn_augment_hits),
"total_augment_calls": sum(gn_augment_calls),
"augment_hit_rate": sum(gn_augment_hits) / max(sum(gn_augment_calls), 1) if gn_augment_calls else 0,
}
def run_swebench_evaluation(results_dir: Path, run_id: str, subset: str = "lite") -> dict | None:
"""
Run the official SWE-bench evaluation on predictions.
Requires: pip install swebench
"""
preds_path = results_dir / run_id / "preds.json"
if not preds_path.exists():
return None
dataset_mapping = {
"lite": "princeton-nlp/SWE-Bench_Lite",
"verified": "princeton-nlp/SWE-Bench_Verified",
"full": "princeton-nlp/SWE-Bench",
}
try:
eval_output = results_dir / run_id / "swebench_eval"
cmd = [
sys.executable, "-m", "swebench.harness.run_evaluation",
"--dataset_name", dataset_mapping.get(subset, subset),
"--predictions_path", str(preds_path),
"--max_workers", "4",
"--run_id", run_id,
"--output_dir", str(eval_output),
]
logger.info(f"Running SWE-bench evaluation for {run_id}...")
result = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
if result.returncode == 0:
# Parse evaluation results
report_path = eval_output / run_id / "results.json"
if report_path.exists():
return json.loads(report_path.read_text())
logger.error(f"SWE-bench eval failed: {result.stderr[:500]}")
return None
except Exception as e:
logger.error(f"SWE-bench eval error: {e}")
return None
# ─── CLI Commands ───────────────────────────────────────────────────────────
@app.command()
def summary(
results_dir: str = typer.Argument(..., help="Path to results directory"),
format: str = typer.Option("table", "--format", help="Output format: table, markdown, json, csv"),
swebench_eval: bool = typer.Option(False, "--swebench-eval", help="Run official SWE-bench test evaluation"),
subset: str = typer.Option("lite", "--subset", help="SWE-bench subset (for --swebench-eval)"),
):
"""Generate comparative analysis of evaluation results."""
results_path = Path(results_dir)
if not results_path.exists():
console.print(f"[red]Results directory not found: {results_path}[/red]")
raise typer.Exit(1)
runs = load_run_results(results_path)
if not runs:
console.print("[yellow]No evaluation results found[/yellow]")
raise typer.Exit(0)
console.print(f"\n[bold]Found {len(runs)} evaluation runs[/bold]\n")
# Compute metrics per run
all_metrics = {}
for run_id, run_data in runs.items():
model, mode = parse_run_id(run_id)
metrics = compute_metrics(run_data)
metrics["model"] = model
metrics["mode"] = mode
# Optionally run SWE-bench evaluation
if swebench_eval:
eval_result = run_swebench_evaluation(results_path, run_id, subset)
if eval_result:
metrics["resolved"] = eval_result.get("resolved", 0)
metrics["resolve_rate"] = eval_result.get("resolved", 0) / max(metrics["n_instances"], 1)
all_metrics[run_id] = metrics
if format == "table":
_print_table(all_metrics)
elif format == "markdown":
_print_markdown(all_metrics)
elif format == "json":
console.print(json.dumps(all_metrics, indent=2))
elif format == "csv":
_print_csv(all_metrics)
@app.command()
def compare_modes(
results_dir: str = typer.Argument(..., help="Path to results directory"),
model: str = typer.Option(..., "-m", "--model", help="Model to compare across modes"),
):
"""Compare modes for a specific model (baseline vs mcp vs augment vs full)."""
results_path = Path(results_dir)
runs = load_run_results(results_path)
# Filter to the specified model
model_runs = {
run_id: data for run_id, data in runs.items()
if parse_run_id(run_id)[0] == model
}
if not model_runs:
console.print(f"[yellow]No results found for model: {model}[/yellow]")
raise typer.Exit(1)
console.print(f"\n[bold]Mode comparison for {model}[/bold]\n")
metrics = {}
for run_id, run_data in model_runs.items():
_, mode = parse_run_id(run_id)
metrics[mode] = compute_metrics(run_data)
mode_order = [
mode
for mode in ["baseline", "native", "native_augment", "mcp", "augment", "full"]
if mode in metrics
] or sorted(metrics.keys())
# Print comparison table
table = Table(title=f"Mode Comparison: {model}")
table.add_column("Metric", style="bold")
for mode in mode_order:
table.add_column(mode, justify="right")
rows = [
("Instances", "n_instances", "d"),
("With Patch", "n_with_patch", "d"),
("Patch Rate", "patch_rate", ".1%"),
("Total Cost", "total_cost", "$.4f"),
("Avg Cost", "avg_cost", "$.4f"),
("Total API Calls", "total_api_calls", "d"),
("Avg API Calls", "avg_api_calls", ".1f"),
("GN Tool Calls", "total_gn_tool_calls", "d"),
("Augment Hits", "total_augment_hits", "d"),
("Augment Hit Rate", "augment_hit_rate", ".1%"),
]
for label, key, fmt in rows:
values = []
for mode in mode_order:
if mode in metrics:
v = metrics[mode].get(key, 0)
if fmt == ".1%":
values.append(f"{v:.1%}")
elif fmt == "$.4f":
values.append(f"${v:.4f}")
elif fmt == ".1f":
values.append(f"{v:.1f}")
else:
values.append(str(v))
table.add_row(label, *values)
# Add delta rows (improvement over baseline)
if "baseline" in metrics:
baseline_cost = metrics["baseline"]["avg_cost"]
baseline_calls = metrics["baseline"]["avg_api_calls"]
table.add_section()
for mode in mode_order:
if mode == "baseline":
continue
mode_cost = metrics[mode]["avg_cost"]
mode_calls = metrics[mode]["avg_api_calls"]
cost_delta = ((mode_cost - baseline_cost) / max(baseline_cost, 0.001)) * 100
calls_delta = ((mode_calls - baseline_calls) / max(baseline_calls, 1)) * 100
cost_str = f"{cost_delta:+.1f}%"
calls_str = f"{calls_delta:+.1f}%"
# Color-code: negative is good (cheaper/fewer calls)
cost_color = "green" if cost_delta < 0 else "red"
calls_color = "green" if calls_delta < 0 else "red"
console.print(f" {mode} vs baseline: cost [{cost_color}]{cost_str}[/{cost_color}], calls [{calls_color}]{calls_str}[/{calls_color}]")
console.print(table)
@app.command()
def gitnexus_usage(
results_dir: str = typer.Argument(..., help="Path to results directory"),
):
"""Analyze GitNexus tool usage patterns across all runs."""
results_path = Path(results_dir)
runs = load_run_results(results_path)
console.print("\n[bold]GitNexus Tool Usage Analysis[/bold]\n")
table = Table(title="Tool Usage by Run")
table.add_column("Run", style="bold")
for key in TOOL_METRIC_KEYS:
table.add_column(key, justify="right")
table.add_column("Total", justify="right")
table.add_column("Augment Hits", justify="right")
for run_id, run_data in sorted(runs.items()):
_, mode = parse_run_id(run_id)
if mode == "baseline":
continue
# Aggregate tool calls across trajectories
tool_totals: dict[str, int] = {key: 0 for key in TOOL_METRIC_KEYS}
augment_hits = 0
for traj in run_data.get("trajectories", {}).values():
gn = traj.get("info", {}).get("gitnexus", {}).get("metrics", {})
for tool, count in gn.get("tool_calls", {}).items():
tool_totals[tool] = tool_totals.get(tool, 0) + count
augment_hits += gn.get("augmentation_hits", 0)
# Also check summary
for r in run_data.get("summary", {}).get("results", []):
gn = r.get("gitnexus_metrics", {})
for tool, count in gn.get("tool_calls", {}).items():
tool_totals[tool] = tool_totals.get(tool, 0) + count
augment_hits += gn.get("augmentation_hits", 0)
total = sum(tool_totals.values())
if total > 0 or augment_hits > 0:
table.add_row(
run_id,
*[str(tool_totals.get(key, 0)) for key in TOOL_METRIC_KEYS],
str(total),
str(augment_hits),
)
console.print(table)
# ─── Output Formatters ─────────────────────────────────────────────────────
def _print_table(all_metrics: dict):
"""Print rich table summary."""
table = Table(title="Evaluation Results")
table.add_column("Run", style="bold")
table.add_column("Model")
table.add_column("Mode")
table.add_column("N", justify="right")
table.add_column("Patched", justify="right")
table.add_column("Rate", justify="right")
table.add_column("Cost", justify="right")
table.add_column("Calls", justify="right")
table.add_column("GN Tools", justify="right")
for run_id, m in sorted(all_metrics.items()):
resolved_str = ""
if "resolve_rate" in m:
resolved_str = f" ({m['resolve_rate']:.0%})"
table.add_row(
run_id,
m["model"],
m["mode"],
str(m["n_instances"]),
str(m["n_with_patch"]),
f"{m['patch_rate']:.0%}{resolved_str}",
f"${m['total_cost']:.2f}",
str(m["total_api_calls"]),
str(m["total_gn_tool_calls"]) if m["total_gn_tool_calls"] > 0 else "-",
)
console.print(table)
def _print_markdown(all_metrics: dict):
"""Print markdown table."""
print("| Run | Model | Mode | N | Patched | Rate | Cost | Calls | GN Tools |")
print("|-----|-------|------|---|---------|------|------|-------|----------|")
for run_id, m in sorted(all_metrics.items()):
gn = str(m["total_gn_tool_calls"]) if m["total_gn_tool_calls"] > 0 else "-"
print(f"| {run_id} | {m['model']} | {m['mode']} | {m['n_instances']} | {m['n_with_patch']} | {m['patch_rate']:.0%} | ${m['total_cost']:.2f} | {m['total_api_calls']} | {gn} |")
def _print_csv(all_metrics: dict):
"""Print CSV output."""
print("run_id,model,mode,n_instances,n_with_patch,patch_rate,total_cost,avg_cost,total_api_calls,avg_api_calls,total_gn_tool_calls,total_augment_hits,augment_hit_rate")
for run_id, m in sorted(all_metrics.items()):
print(
f"{run_id},{m['model']},{m['mode']},{m['n_instances']},{m['n_with_patch']},"
f"{m['patch_rate']:.4f},{m['total_cost']:.4f},{m['avg_cost']:.4f},"
f"{m['total_api_calls']},{m['avg_api_calls']:.1f},{m['total_gn_tool_calls']},"
f"{m['total_augment_hits']},{m['augment_hit_rate']:.4f}"
)
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
app()