194 lines
7.0 KiB
Python
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
|
|
),
|
|
}
|