Given a bug report and a snapshot of a repository's code, predict which source files need to change to fix the bug. This repo implements an LLM-based bug localizer and evaluates it against public bug-localization benchmarks — Bench4BL (primary), SWE-bench Verified, and BeetleBox (secondary/tertiary).
Benchmark caveat: OpenAI has stopped evaluating on SWE-bench Verified and now recommends SWE-bench Pro instead, citing tests that reject functionally correct solutions and possible train/test contamination. This project still targets Verified for continuity with prior results — treat any accuracy numbers here with that caveat in mind, and consider SWE-bench Pro or a time-separated benchmark for future work.
Forked from ekaramustafa/bug-localization — see ORIGIN.md for what's changed since.
See docs/literature_review.md for the survey of recent LLM-based bug localization/retrieval papers informing this project's direction, or docs/PROGRESS_REPORT.md for a chronological status report with real results (what's done, current findings/limitations, what we can and can't claim yet).
- Dataset (
dataset/) loads bug instances (bug report + target repo + base commit + ground-truth changed files) from Bench4BL (primary), SWE-bench Verified, or BeetleBox. - Repo file access: for each bug instance, the list of candidate code files (and their contents) is pulled either from the GitHub API or from a local git cache (see Offline repo access below).
- Method (
method/) sends the bug report plus the candidate file list/contents to an LLM and asks it to rank/select the files most likely to need changes. - Evaluation (
method/evaluate.py) compares predicted files against ground truth and reports accuracy (hit@k), precision, recall, and F1.
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txtCreate a .env file in the repo root:
OPENROUTER_API_KEY=...
GITHUB_TOKEN=...
OPENAI_API_KEY=...
GITHUB_TOKENis used to raise GitHub API rate limits when fetching repo file listings live (optional but recommended).OPENROUTER_API_KEYis required for the defaultopenroutermethod.OPENAI_API_KEYis only needed for theopenai/openai-freemethods.
python -m pytestCovers dataset/localizability.py, method/bm25_retriever.py, evaluation/ (manifest, screening, failure attribution), method/evaluate.py, and the dataset/ helper/model/repo-cache modules -- unit tests plus a few integration tests against a locally mirrored repo (see Offline repo access), which skip gracefully if none is mirrored yet. No network access or API keys required. tests/openrouter_key_test.py and tests/github_token_test.py are separate standalone scripts (not pytest) for checking your own API keys are valid -- run them directly, not via pytest.
python main.py --method openrouter --dataset bench4bl --model gpt-oss-20b --sample-size NArguments (main.py):
--method:openai,openai-free,openrouter(default), orunsloth(currently disabled, see Notes)--dataset:bench4bl(default, primary benchmark),swebench, orbeetlebox--model: model name, see Models below (defaultgpt-oss-20b)--sample-size: number of bug instances to sample--max-files: cap the number of code files sent to the model per bug (cheap smoke-testing only — may truncate away the actual ground-truth file, so don't use it for real evaluation runs)--device:cuda/cpu/auto(local inference only)--bm25-top-k: narrow code files to the top-K most relevant via BM25 before prompting (disabled by default)--bm25-skeleton/--bm25-symbols[--bm25-symbols-imports]: BM25 document representation when--bm25-top-kis set (see Symbol-aware BM25 document representations);--bm25-symbolstakes precedence if both are passed, plain path-only BM25 if neither is--output: optional path to write the run config + full evaluation results (including per-bug candidate files and ground truths) as JSON
Results are printed as an overall metrics table (accuracy/precision/recall/F1) plus per-bug breakdowns; see results/README.md for saved runs.
Models available per --method, and their evaluation status. Numbers are intentionally left blank — sample size and model set are both still changing, so any fixed number here would go stale immediately. See the most recent run logs/commits for current results.
| Method | --model |
Backend | Cost | Top-1 Accuracy |
|---|---|---|---|---|
openrouter |
gpt-oss-20b (default) |
openai/gpt-oss-20b:free |
free | ... |
openrouter |
qwen-coder |
qwen/qwen3-coder |
paid | ... |
openrouter |
gpt-4o-mini |
openai/gpt-4o-mini |
paid (cheap) | ... |
openrouter |
gemini-flash |
google/gemini-flash-1.5 |
paid (cheap) | ... |
openrouter |
haiku |
anthropic/claude-haiku-3-5 |
paid (cheap) | ... |
openai |
gpt-5-nano (default) or any --model |
OpenAI direct | paid | ... |
openai-free |
configurable | OpenAI direct | paid | ... |
The GitHub API path (dataset/utils.get_code_files) times out on networks without outbound internet access (e.g. HPC clusters like MN5). To avoid this, repos can be mirrored locally as bare git clones and read from disk instead:
python scripts/mirror_repos.py --dataset swebench --sample-size NThis clones every repo referenced by the sampled bug instances into repo_cache/<owner>__<repo>.git (bare clones, gitignored). dataset/utils.get_code_files automatically prefers the local cache when a repo is already mirrored there, falling back to the GitHub API otherwise. dataset/repo_cache.py also fetches a specific commit directly by SHA if it isn't reachable from any already-fetched ref.
Cache location can be overridden with the REPO_CACHE_DIR environment variable.
Not every ground-truth file in a bug instance can actually be retrieved: some are introduced by the fixing commit itself and therefore don't exist in the before-fix corpus that retrieval searches. Scoring those as "retrieval failures" understates real performance. dataset/localizability.py classifies each ground-truth path as exists_before_fix, deleted_by_fix (present before, removed by the fix -- still a valid retrieval target), added_by_fix (not localizable), missing_unresolved, or api_error (never cached, so it's retried next run instead of remembered as final):
python scripts/localizability_report.py --dataset swebench --sample-size 30 --output results/localizability_swebench_30.jsonPrints classification counts and three coverage metrics (raw / available-corpus / localizable coverage, averaged across sampled instances), and caches classification results in repo_cache/localizability_cache.json.
Comparing retrieval/reranking approaches against different ad-hoc samples makes results non-comparable. evaluation/manifest.py builds a deterministic, seeded sample capped at --max-per-repo instances per repository (so no single repo dominates), saved with a stable content-derived ID:
python scripts/generate_evaluation_manifest.py --dataset swebench --size 24 --pool-size 100 --seed 42 --max-per-repo 2evaluation/screening.py then runs path-only BM25 over every instance in a saved manifest, reporting the best rank among each instance's localizable ground-truth files (via the localizability diagnostics above), Hit@1/5/10/100/200, recall@100/200, and a difficulty band (easy / medium / hard / outside_top200 / no_localizable_gt):
python scripts/run_bm25_screening.py --manifest results/manifests/swebench-multi-n24-s42-<hash>.json --output results/screening_swebench_24.jsonThe screening script re-derives the same seeded pool the manifest was built from (rather than reprocessing the whole dataset) to stay cheap, and warns on any instance-ID/repo mismatch.
A low score can mean two different things: the correct file never made it into the candidate set BM25 hands to the LLM (a retrieval failure, which reranking can never fix), or it was in the candidate set but the LLM didn't surface it (a reranking failure). Conflating the two makes it easy to blame the wrong stage.
python scripts/run_failure_attribution.py --manifest results/manifests/<manifest_id>.json --candidate-size 100By default this only does the free, offline split (reusing the BM25 screening ranks -- no LLM calls). Add --run-oracle to additionally run the oracle diagnostic: force-inject every localizable ground-truth file into the candidate set (so retrieval recall is artificially 100% for that call) and measure how well the LLM reranker places them, isolating pure reranking ability from retrieval quality. --run-oracle calls the LLM once per manifest instance and therefore costs real API usage -- it prints an estimated prompt-token count before making any calls, and defaults to the free gpt-oss-20b OpenRouter model (override with --model).
method/bm25_retriever.py supports four document representations for the BM25 candidate pre-filter, moving beyond bare file paths:
| Representation | What each file's BM25 document contains |
|---|---|
path_only (rank_files_bm25) |
Path tokens only |
skeleton (rank_files_bm25_with_skeleton) |
Path tokens + module docstring + class/function names |
symbols_with_imports (rank_files_bm25_with_symbols, default) |
Path tokens + class/function/method names + imported module/name tokens |
symbols_no_imports (rank_files_bm25_with_symbols(include_imports=False)) |
Path tokens + class/function/method names only |
All are Python-AST-based (SWE-bench Verified's only language today) and fall back to path-only tokens for any file whose content can't be read or parsed -- never a live network call, only the offline repo_cache. main.py wires up path_only (default), skeleton (--bm25-skeleton), and symbols_with_imports/symbols_no_imports (--bm25-symbols, --bm25-symbols-imports to include imports) for end-to-end runs. Compare all four on the same manifest without spending anything on an LLM:
python scripts/compare_bm25_representations.py --manifest results/manifests/<manifest_id>.json --output results/bm25_representation_comparison.jsonPrints macro Hit@1/5/10, MRR, and MAP per representation, plus each one's difficulty-band distribution -- free and offline, no LLM calls. Retrieval still operates at file granularity (ground truth in this dataset is file-level); symbols/imports are used to build a richer document per file, not to retrieve individual symbols.
Both dataset loaders normally pull from Hugging Face (SWE-bench/SWE-bench_Verified, bug-localization/BeetleBox). For offline environments, a pre-downloaded copy (via datasets.save_to_disk) can be loaded instead by setting:
SWEBENCH_LOCAL_PATH=/path/to/local/swebench_verified
BEETLEBOX_LOCAL_PATH=/path/to/local/beetlebox
Because clusters like MN5 have no outbound internet access, the LLM API call itself cannot run there — only the dataset loading, repo cache, and prompt-construction machinery can be validated on-cluster. The intended workflow is:
- Produce real results locally (Mac/laptop with internet) using
repo_cache/and the OpenRouter/OpenAI methods. - Ship the source tree, a prebuilt wheelhouse (
requirements-mn5.txt, offline pip wheels), and the populatedrepo_cache/to the cluster. - Use the offline SLURM scaffold to validate the pipeline end-to-end on-cluster (dataset loading → repo cache reads → prompt construction), accepting that the final inference call will fail there by design.
requirements-mn5.txt is a trimmed dependency list for this offline transfer — it excludes the unused local-inference/RAG stack (torch, transformers, accelerate, bitsandbytes, sentence-transformers, langchain/langgraph), which are not imported by any active code path.
See docs/project_structure.md for a full file-by-file breakdown, or docs/architecture.md for package-dependency and runtime data-flow diagrams.
dataset/ Dataset loaders (SWEBench, BeetleBox), repo cache, token/utility helpers
method/ Localizer implementations (OpenAI, OpenAI-free, OpenRouter), prompt generation, evaluation
scripts/ Standalone utilities (mirror_repos.py for offline repo caching)
tests/ Standalone .env key sanity checks (not pytest) -- run directly to confirm API keys work
main.py CLI entry point
requirements.txt Full dependency set (includes unused local-inference/RAG deps, kept for compatibility)
requirements-mn5.txt Trimmed dependency set for offline HPC transfer
- The
unsloth/local-inference method (method/opensource_localizer.py) and the RAG-based localizer (method/rag_localizer.py) are present but not used in the current pipeline — imports are lazy/optional and no CLI flag enables RAG by default. method/evaluate.pyreports hit@k (default k=1), precision, recall, and F1, both per-bug and aggregated.