Files
2026-07-13 12:42:18 +08:00

194 lines
7.0 KiB
Python

"""Agent baseline benchmark: grep-and-read-top-k versus a graph query.
The whole-corpus baseline in the standalone token benchmark is an upper
bound no real agent pays: a competent agent greps for identifiers from the
question and reads only the best-matching files. This benchmark measures
that realistic baseline:
1. Derive search terms from the question (identifier-shaped tokens via
``search.extract_query_identifiers`` plus plain keywords).
2. Pure-python grep over the corpus (no external ``rg``/``grep`` binary),
ranking files by total case-insensitive match count.
3. Read the top-k files (k=3) and token-count them with the chars/4 utility
(``token_benchmark.estimate_tokens``) as ``baseline_tokens``.
4. Compare against the graph-query cost for the same question — hybrid
search hits plus one hop of neighbor edges, the same accounting used by
``code_review_graph/token_benchmark.py``.
Questions come from ``agent_questions:`` in the repo config, falling back to
the ``search_queries`` query strings when absent.
Failure semantics match the other benchmarks: a thrown search is recorded
with ``status="error"`` and excluded from aggregates; rows where either side
of the ratio is zero get ``status="no_graph_results"`` /
``status="no_baseline_match"`` and are likewise excluded.
"""
from __future__ import annotations
import logging
import statistics
from collections.abc import Iterator
from pathlib import Path
from code_review_graph.token_benchmark import estimate_tokens
logger = logging.getLogger(__name__)
DEFAULT_TOP_K = 3
_SOURCE_EXTS = (
".py", ".js", ".ts", ".tsx", ".go", ".rs", ".java",
".c", ".cpp", ".h", ".rb", ".php", ".swift", ".kt",
)
_SKIP_DIRS = {
".git", ".hg", ".svn", "node_modules", "__pycache__",
".code-review-graph", ".venv", "venv", "dist", "build",
}
_STOPWORDS = {
"how", "does", "do", "the", "a", "an", "is", "are", "was", "what",
"where", "when", "which", "who", "why", "and", "or", "in", "on", "of",
"to", "for", "with", "via", "into", "from", "this", "that", "it", "its",
}
def derive_search_terms(question: str) -> list[str]:
"""Derive lowercase grep terms: identifiers first, then plain keywords.
Identifier-shaped tokens (``Client.request``, ``get_users``, ``APIRoute``)
are extracted via ``search.extract_query_identifiers``; remaining words of
3+ characters that are not stopwords are appended. Order is deterministic.
"""
from code_review_graph.search import extract_query_identifiers
terms: list[str] = []
seen: set[str] = set()
for ident in extract_query_identifiers(question):
if ident not in seen:
seen.add(ident)
terms.append(ident)
for word in question.split():
w = word.strip(".,;:!?\"'()[]{}`").lower()
if len(w) >= 3 and w not in _STOPWORDS and w not in seen:
seen.add(w)
terms.append(w)
return terms
def iter_source_files(repo_path: Path) -> Iterator[Path]:
"""Yield source files under *repo_path*, skipping vendored/VCS dirs."""
for path in sorted(repo_path.rglob("*")):
if path.suffix not in _SOURCE_EXTS or not path.is_file():
continue
if any(part in _SKIP_DIRS for part in path.parts):
continue
yield path
def grep_rank(
repo_path: Path, terms: list[str], k: int = DEFAULT_TOP_K,
) -> list[tuple[str, int]]:
"""Rank source files by total case-insensitive term matches; take top-k.
Pure python — no external grep/rg dependency. Deterministic: ties break
on the relative path. Files with zero matches are dropped.
"""
lowered = [t.lower() for t in terms if t]
if not lowered:
return []
scores: list[tuple[str, int]] = []
for path in iter_source_files(repo_path):
try:
text = path.read_text(encoding="utf-8", errors="replace").lower()
except OSError:
continue
count = sum(text.count(term) for term in lowered)
if count > 0:
scores.append((str(path.relative_to(repo_path)), count))
scores.sort(key=lambda item: (-item[1], item[0]))
return scores[:k]
def run(repo_path: Path, store, config: dict) -> list[dict]:
"""Run the agent baseline benchmark for one repo."""
questions = list(config.get("agent_questions") or [])
if not questions:
questions = [sq["query"] for sq in config.get("search_queries", [])]
k = int(config.get("agent_baseline_top_k", DEFAULT_TOP_K))
results: list[dict] = []
for question in questions:
terms = derive_search_terms(question)
top = grep_rank(repo_path, terms, k=k)
baseline_tokens = 0
for rel, _count in top:
try:
baseline_tokens += estimate_tokens(
(repo_path / rel).read_text(encoding="utf-8", errors="replace")
)
except OSError:
continue
row: dict = {
"repo": config["name"],
"question": question,
"terms": " ".join(terms),
"files_matched": len(top),
"top_files": ";".join(rel for rel, _ in top),
"baseline_tokens": baseline_tokens,
"graph_tokens": "",
"baseline_to_graph_ratio": "",
"status": "ok",
"error": "",
}
try:
from code_review_graph.search import hybrid_search
hits = hybrid_search(store, question, limit=5)
except Exception as exc:
logger.warning("hybrid_search failed on %r: %s", question, exc)
row["status"] = "error"
row["error"] = str(exc)[:200]
results.append(row)
continue
# Same accounting as the standalone token benchmark: search hits
# plus up to 5 outgoing edges of neighbor context per hit.
graph_tokens = 0
for hit in hits:
graph_tokens += estimate_tokens(str(hit))
qn = hit.get("qualified_name", "")
for edge in store.get_edges_by_source(qn)[:5]:
graph_tokens += estimate_tokens(str(edge))
row["graph_tokens"] = graph_tokens
if baseline_tokens > 0 and graph_tokens > 0:
row["baseline_to_graph_ratio"] = round(baseline_tokens / graph_tokens, 1)
elif graph_tokens == 0:
row["status"] = "no_graph_results"
else:
row["status"] = "no_baseline_match"
results.append(row)
return results
def aggregate(results: list[dict]) -> dict:
"""Aggregate over rows where both sides of the comparison exist."""
ok = [r for r in results if r.get("status") == "ok"]
ratios = [float(r["baseline_to_graph_ratio"]) for r in ok]
return {
"total_rows": len(results),
"ok_rows": len(ok),
"error_rows": sum(1 for r in results if r.get("status") == "error"),
"median_baseline_to_graph_ratio": (
round(statistics.median(ratios), 1) if ratios else None
),
"mean_baseline_to_graph_ratio": (
round(statistics.mean(ratios), 1) if ratios else None
),
}