Skip to content

Commit bdedb74

Browse files
feat(bench): add indexed benchmark runner with lite toolkit fallback
Mirrors the production review pipeline: builds CodebaseIndex + graph context, falls back to lite toolkit (grep/read/list) when embedding model is unavailable. Includes embedding model probe to avoid slow retry loops on VRAM contention. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ac9f2de commit bdedb74

1 file changed

Lines changed: 324 additions & 0 deletions

File tree

benchmarks/martian/run_indexed.py

Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
1+
# SPDX-License-Identifier: MIT
2+
"""Step 2b: Run Grippy indexed reviews (with codebase tools + graph context).
3+
4+
Same as run_grippy.py but builds CodebaseIndex + SQLiteGraphStore for each
5+
repo clone, then passes CodebaseToolkit + graph context to the reviewer agent.
6+
This mirrors the production review pipeline (review.py) as closely as possible.
7+
8+
Requires repo clones at BENCH_REPOS_DIR (default: /tmp/grippy-bench-repos/).
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import itertools
14+
import json
15+
import re
16+
import sys
17+
from datetime import UTC, datetime
18+
from pathlib import Path
19+
20+
from benchmarks.martian.config import BenchConfig
21+
from benchmarks.martian.fetch_diffs import parse_golden_prs
22+
from benchmarks.martian.run_grippy import (
23+
_format_rules_for_agent,
24+
format_finding_as_comment,
25+
is_inline_finding,
26+
)
27+
28+
BENCH_REPOS_DIR = Path("/tmp/grippy-bench-repos")
29+
30+
31+
def _extract_touched_files(diff_text: str) -> list[str]:
32+
"""Extract file paths from diff headers."""
33+
return re.findall(r"^diff --git a/.+ b/(.+)$", diff_text, re.MULTILINE)
34+
35+
36+
def _repo_name_from_slug(slug: str) -> str:
37+
"""Extract repo name from slug like 'keycloak_PR32918'."""
38+
return slug.rsplit("_PR", 1)[0]
39+
40+
41+
def _build_lite_toolkit(repo_root: Path) -> list:
42+
"""Create a filesystem-only toolkit (grep, read, list) without embeddings.
43+
44+
Used when the embedding model is unavailable (e.g. VRAM contention).
45+
This still provides the agent with codebase access for context-aware review,
46+
just without semantic search_code.
47+
"""
48+
from agno.tools.function import Function
49+
from agno.tools.toolkit import Toolkit
50+
51+
from grippy.codebase import _make_grep_code, _make_list_files, _make_read_file
52+
53+
tk = Toolkit(name="codebase")
54+
for fn in [_make_grep_code(repo_root), _make_read_file(repo_root), _make_list_files(repo_root)]:
55+
func = Function.from_callable(fn)
56+
tk.functions[func.name] = func
57+
return [tk]
58+
59+
60+
def _probe_embedding_model(base_url: str, api_key: str) -> bool:
61+
"""Quick probe to check if the embedding model is available."""
62+
import httpx
63+
64+
try:
65+
resp = httpx.post(
66+
f"{base_url}/embeddings",
67+
json={"model": "text-embedding-qwen3-embedding-4b", "input": "test"},
68+
headers={"Authorization": f"Bearer {api_key}"},
69+
timeout=5.0,
70+
)
71+
return resp.status_code == 200
72+
except Exception:
73+
return False
74+
75+
76+
def _build_codebase_tools(
77+
repo_root: Path,
78+
data_dir: Path,
79+
config: BenchConfig,
80+
) -> list:
81+
"""Build CodebaseToolkit. Falls back to lite (filesystem-only) on failure.
82+
83+
Probes the embedding model first to avoid slow retry loops when the model
84+
is unavailable (e.g. VRAM contention with the review model).
85+
"""
86+
if not _probe_embedding_model(config.base_url, config.api_key):
87+
print(" Embedding model unavailable — using lite toolkit (grep/read/list)")
88+
return _build_lite_toolkit(repo_root)
89+
90+
try:
91+
from agno.vectordb.lancedb import LanceDb
92+
from agno.vectordb.search import SearchType
93+
94+
from grippy.codebase import CodebaseIndex, CodebaseToolkit
95+
from grippy.embedder import create_embedder
96+
97+
cb_embedder = create_embedder(
98+
transport=config.transport,
99+
model="text-embedding-qwen3-embedding-4b",
100+
base_url=config.base_url,
101+
api_key=config.api_key,
102+
)
103+
lance_dir = data_dir / "lance"
104+
lance_dir.mkdir(parents=True, exist_ok=True)
105+
106+
vector_db = LanceDb(
107+
uri=str(lance_dir),
108+
table_name="codebase_chunks",
109+
search_type=SearchType.hybrid,
110+
use_tantivy=False,
111+
embedder=cb_embedder,
112+
)
113+
cb_index = CodebaseIndex(
114+
repo_root=repo_root,
115+
vector_db=vector_db,
116+
embedder=cb_embedder,
117+
data_dir=data_dir,
118+
)
119+
chunk_count = cb_index.build(force=False)
120+
if chunk_count > 0:
121+
print(f" Indexed {chunk_count} chunks")
122+
else:
123+
print(" Codebase index up-to-date (cached)")
124+
125+
return [CodebaseToolkit(index=cb_index, repo_root=repo_root)]
126+
except Exception as exc:
127+
print(f" WARNING: Full index failed ({exc}), falling back to lite toolkit")
128+
return _build_lite_toolkit(repo_root)
129+
130+
131+
def _build_graph(repo_root: Path, data_dir: Path) -> "SQLiteGraphStore | None":
132+
"""Build import graph from .py files. Returns None if no Python files found."""
133+
from grippy.graph_store import SQLiteGraphStore
134+
from grippy.imports import extract_imports
135+
136+
graph = SQLiteGraphStore(db_path=data_dir / "navi-graph.db")
137+
py_files = list(itertools.islice(repo_root.rglob("*.py"), 5000))
138+
if not py_files:
139+
print(" No Python files — graph skipped")
140+
return graph
141+
142+
for py_f in py_files:
143+
try:
144+
rel = str(py_f.relative_to(repo_root))
145+
except ValueError:
146+
continue
147+
try:
148+
imports = extract_imports(py_f)
149+
except Exception:
150+
continue
151+
for imp in imports:
152+
graph.add_edge(
153+
src_type="FILE",
154+
src_id=rel,
155+
rel="IMPORTS",
156+
dst_type="FILE",
157+
dst_id=imp,
158+
data={},
159+
)
160+
print(f" Graph: {len(py_files)} Python files scanned")
161+
return graph
162+
163+
164+
def review_single_pr_indexed(
165+
*,
166+
diff_text: str,
167+
pr_title: str,
168+
repo_root: Path,
169+
data_dir: Path,
170+
config: BenchConfig,
171+
) -> dict:
172+
"""Run indexed Grippy review on a single diff."""
173+
from grippy.agent import create_reviewer, format_pr_context
174+
from grippy.codebase import sanitize_tool_hook
175+
from grippy.graph_context import build_context_pack, format_context_for_llm
176+
from grippy.retry import run_review
177+
from grippy.rules import load_profile, run_rules
178+
179+
# Deterministic rules
180+
profile_cfg = load_profile(cli_profile=config.profile)
181+
rule_findings = run_rules(diff_text, profile_cfg)
182+
rule_text = ""
183+
if rule_findings and config.profile != "general":
184+
rule_text = _format_rules_for_agent(rule_findings)
185+
186+
# Build codebase tools (full index or lite filesystem fallback)
187+
tools = _build_codebase_tools(repo_root, data_dir, config)
188+
189+
# Build graph + context
190+
graph_context_text = ""
191+
try:
192+
graph = _build_graph(repo_root, data_dir)
193+
if graph:
194+
touched = _extract_touched_files(diff_text)
195+
pack = build_context_pack(graph, touched_files=touched, author_login="benchmark")
196+
graph_context_text = format_context_for_llm(pack)
197+
if graph_context_text:
198+
print(f" Graph context: {len(graph_context_text)} chars")
199+
except Exception as exc:
200+
print(f" WARNING: Graph build failed: {exc}")
201+
202+
agent = create_reviewer(
203+
model_id=config.model_id,
204+
base_url=config.base_url,
205+
api_key=config.api_key,
206+
transport=config.transport,
207+
mode=config.mode,
208+
tools=tools or None,
209+
tool_call_limit=10 if tools else None,
210+
tool_hooks=[sanitize_tool_hook] if tools else None,
211+
include_rule_findings=bool(rule_text),
212+
)
213+
message = format_pr_context(
214+
title=pr_title,
215+
author="benchmark",
216+
branch="feature → main",
217+
diff=diff_text,
218+
rule_findings=rule_text,
219+
file_context=graph_context_text,
220+
)
221+
review = run_review(agent, message)
222+
return review.model_dump()
223+
224+
225+
def run_all_indexed(config: BenchConfig | None = None, resume: bool = False) -> None:
226+
"""Run indexed Grippy reviews on all fetched diffs."""
227+
config = config or BenchConfig.from_env()
228+
prs = parse_golden_prs(config.golden_dir)
229+
230+
diff_dir = config.output_dir / "diffs"
231+
review_dir = config.output_dir / "reviews"
232+
comment_dir = config.output_dir / "comments"
233+
review_dir.mkdir(parents=True, exist_ok=True)
234+
comment_dir.mkdir(parents=True, exist_ok=True)
235+
236+
manifest = []
237+
for i, pr in enumerate(prs, 1):
238+
slug = f"{pr['repo']}_PR{pr['pr_number']}"
239+
review_path = review_dir / f"{slug}.json"
240+
comment_path = comment_dir / f"{slug}.json"
241+
242+
if resume and review_path.exists() and comment_path.exists():
243+
print(f"[{i}/{len(prs)}] {slug} — skipped (resume)")
244+
continue
245+
246+
diff_path = diff_dir / f"{slug}.diff"
247+
if not diff_path.exists():
248+
# Try shared diff dir (output/ parent)
249+
shared_diff_dir = config.output_dir.parent / "output" / "diffs"
250+
diff_path = shared_diff_dir / f"{slug}.diff"
251+
252+
if not diff_path.exists():
253+
print(f"[{i}/{len(prs)}] {slug} — ERROR: diff not found", file=sys.stderr)
254+
manifest.append({"pr": slug, "status": "failed", "reason": "diff_not_found"})
255+
continue
256+
257+
repo_name = _repo_name_from_slug(slug)
258+
repo_root = BENCH_REPOS_DIR / repo_name
259+
if not repo_root.exists():
260+
print(f"[{i}/{len(prs)}] {slug} — ERROR: repo clone not found at {repo_root}")
261+
manifest.append({"pr": slug, "status": "failed", "reason": "repo_not_found"})
262+
continue
263+
264+
diff_text = diff_path.read_text()
265+
data_dir = config.output_dir / "data" / repo_name
266+
data_dir.mkdir(parents=True, exist_ok=True)
267+
print(f"[{i}/{len(prs)}] {slug} — indexed review ({len(diff_text)} chars)")
268+
269+
ts = datetime.now(UTC).isoformat()
270+
try:
271+
review_data = review_single_pr_indexed(
272+
diff_text=diff_text,
273+
pr_title=pr["pr_title"],
274+
repo_root=repo_root,
275+
data_dir=data_dir,
276+
config=config,
277+
)
278+
279+
output = {"provenance": config.stamp(), "timestamp": ts, "review": review_data}
280+
review_path.write_text(json.dumps(output, indent=2))
281+
282+
findings = review_data.get("findings", [])
283+
inline_comments = []
284+
general_comments = []
285+
for f in findings:
286+
body = format_finding_as_comment(f)
287+
if is_inline_finding(f):
288+
inline_comments.append(
289+
{"path": f["file"], "line": f["line_start"], "body": body}
290+
)
291+
else:
292+
general_comments.append(body)
293+
294+
comment_path.write_text(
295+
json.dumps({"inline": inline_comments, "general": general_comments}, indent=2)
296+
)
297+
manifest.append({"pr": slug, "status": "ok", "findings": len(findings)})
298+
except Exception as e:
299+
print(f"[{i}/{len(prs)}] {slug} — ERROR: {e}", file=sys.stderr)
300+
manifest.append({"pr": slug, "status": "failed", "reason": str(e)})
301+
302+
manifest_path = config.output_dir / "run_manifest.json"
303+
manifest_path.write_text(
304+
json.dumps(
305+
{"provenance": config.stamp(), "timestamp": datetime.now(UTC).isoformat(), "results": manifest},
306+
indent=2,
307+
)
308+
)
309+
ok = sum(1 for m in manifest if m["status"] == "ok")
310+
fail = sum(1 for m in manifest if m["status"] == "failed")
311+
print(f"Done. {ok} reviewed, {fail} failed, {len(prs) - ok - fail} skipped.")
312+
313+
314+
if __name__ == "__main__":
315+
import os
316+
317+
output_dir = Path(os.environ.get("BENCH_OUTPUT_DIR", str(Path(__file__).parent / "output-devstral-indexed")))
318+
model_id = os.environ.get("GRIPPY_MODEL_ID", "devstral-small-2-24b-instruct-2512")
319+
config = BenchConfig(
320+
model_id=model_id,
321+
output_dir=output_dir,
322+
)
323+
resume = "--resume" in sys.argv
324+
run_all_indexed(config=config, resume=resume)

0 commit comments

Comments
 (0)