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

126 lines
4.2 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Multi-hop retrieval benchmark.
Tests a two-step tool chain that mimics how an LLM agent actually uses the
graph for complex tasks:
1. ``hybrid_search(nl_query)`` to find a starting anchor from a natural-
language question.
2. ``query_graph(pattern, target=anchor)`` to traverse one hop along the
requested edge kind (callers_of / callees_of / tests_for / ...).
For each task the benchmark records:
- ``anchor_found`` — did semantic search return a node whose qualified_name
ends with the expected suffix in the top-K?
- ``anchor_rank`` — index in the search result list (lower is better).
- ``neighbor_count`` — number of neighbors returned by the traversal.
- ``neighbor_recall`` — fraction of ``expected_neighbor_names`` that appear
among the neighbor names.
- ``score`` — ``int(anchor_found) * neighbor_recall``. Range 01.
Tasks are defined per-config under ``multi_hop_tasks:`` in
``code_review_graph/eval/configs/*.yaml``. See
``docs/REPRODUCING.md`` for the schema and the curated canonical task set.
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
def _name_set(rows: list[dict[str, Any]]) -> set[str]:
out: set[str] = set()
for r in rows:
name = (r.get("name") or "").lower()
if name:
out.add(name)
return out
def run(repo_path: Path, store, config: dict) -> list[dict]:
"""Run the multi-hop retrieval benchmark for one repo."""
# Imports are local so an import-time failure in one optional benchmark
# does not poison the whole runner.
from code_review_graph.search import hybrid_search
from code_review_graph.tools.query import query_graph
repo_root = str(repo_path)
results: list[dict] = []
for task in config.get("multi_hop_tasks", []):
task_id = task["id"]
nl_query = task["nl_query"]
suffix = task["anchor_qualified_suffix"].lower()
traversal = task.get("traversal_pattern", "callers_of")
expected = [e.lower() for e in task.get("expected_neighbor_names", [])]
k = int(task.get("k", 10))
# Step 1 — semantic search
try:
hits = hybrid_search(store, nl_query, limit=k)
except Exception as exc: # noqa: BLE001 — benchmark must not abort the runner
logger.warning("hybrid_search failed on %s: %s", task_id, exc)
hits = []
anchor = None
anchor_rank = -1
for i, h in enumerate(hits):
qn = (h.get("qualified_name") or "").lower()
if qn.endswith(suffix):
anchor = h
anchor_rank = i
break
if anchor is None:
results.append({
"repo": config["name"],
"task_id": task_id,
"nl_query": nl_query,
"anchor_found": False,
"anchor_rank": -1,
"neighbor_count": 0,
"expected_count": len(expected),
"matched_count": 0,
"neighbor_recall": 0.0,
"score": 0.0,
})
continue
# Step 2 — single-hop graph traversal from the anchor
try:
trav = query_graph(
pattern=traversal,
target=anchor["qualified_name"],
repo_root=repo_root,
detail_level="standard",
)
except Exception as exc: # noqa: BLE001
logger.warning(
"query_graph(%s) failed on %s: %s", traversal, task_id, exc,
)
trav = {}
rows = trav.get("data") or trav.get("results") or []
names = _name_set(rows)
matched = sum(1 for e in expected if e in names)
recall = matched / len(expected) if expected else 0.0
results.append({
"repo": config["name"],
"task_id": task_id,
"nl_query": nl_query,
"anchor_found": True,
"anchor_rank": anchor_rank,
"neighbor_count": len(rows),
"expected_count": len(expected),
"matched_count": matched,
"neighbor_recall": round(recall, 3),
"score": round(recall, 3),
})
return results