LLM-based bug fix generation and evaluation on the Defects4J benchmark.
Given a Defects4J bug, the experiment generates a candidate fix with a Large
Language Model and evaluates it by running the project's test suite: the fix is
considered fixed when all of the bug's trigger tests pass again and the
patch introduces no new failures (no test that passed before now fails). This
trigger-based criterion — rather than requiring a zero total — is robust to
environment-flaky tests (e.g. SystemUtils' user-home test under HOME=/tmp)
that fail regardless of the patch.
Experiment.pyowns everything Defects4J- and Docker-specific:- Starts an ephemeral Docker container from the
defects4j:3.0.1image, mounting the host working directory as a shared volume (same absolute path inside and outside the container). - Checks out the buggy version (
defects4j checkout -p <project> -v <id>b). - Compiles it and runs the test suite to confirm the bug is present.
- Extracts bug metadata (
defects4j info) — kept inresult.jsonfor reference, no longer passed to the LLM. - Locates the buggy source file(s) via
defects4j export(classes.modified,dir.src.classes) and reads them from the shared volume. - Optionally (see Usage below) gathers three extra pieces of context: the
failing trigger test method(s) — located via
defects4j export -p tests.trigger+dir.src.testsand reduced to just the failing method(s) rather than the whole test file — that test's isolated failure log (defects4j test -t <test>), and the original bug-tracker issue report (fetched from theBug report urlindefects4j info, via the Jira or GitHub REST API, or a generic HTML fetch for other trackers). - Delegates fix generation to
FixGenerator, passing the buggy source contents and any of the optional context gathered above. - Applies the generated diff with
git applyinside the container and validates it by re-runningdefects4j test, then checking the bug's trigger tests pass and no new failures were introduced. - Optionally (
--fixcheck), once the patch is plausible (applied and every trigger test passing), runs FixCheck (vendored infixcheck/) as an overfitting check: it mutates the trigger test's inputs, re-generates the assertions (strategy chosen with--fixcheck-assertions), reruns the variations against the patched program, and measures how similar each failing variation's failure trace is to the original bug's. A failing variation with high similarity is evidence the patch didn't really fix the underlying defect rather than just satisfying the trigger test. This is purely advisory: it never changesfixed, only adds afixcheckblock and afixcheck_suspiciousflag toresult.json. Before reporting those verdicts as results, read docs/fixcheck-verdict-limitations.md: it documents two upstream defects that gutted the check — both now fixed by the patches inscripts/fixcheck-patches/, applied automatically byscripts/buildFixcheck.sh— and the limitations that still remain (role-blind literal mutation, non-reproducible runs). - Writes the validation artifacts (
apply.log,test_before.log,test_after.log) and the combinedresult.json. - Always stops and removes the container at the end.
- Starts an ephemeral Docker container from the
FixGenerator.pyis a dataset- and Docker-agnostic fix generator:- Receives the buggy source contents, and optionally the regression test source, its failure log, and the issue report (it never touches Docker or Defects4J itself).
- Asks the LLM for SEARCH/REPLACE blocks (an exact snippet of the original code plus its replacement) rather than a raw diff. This avoids the line-number and context hallucinations that make LLM-produced diffs fail to apply.
- Anchors each block against the real source (whitespace-tolerant matching)
and builds the unified diff itself with
difflib, so the resulting diff always matches the file. - Returns the diff plus generation metadata, and writes the generation
artifacts (
prompt.txt,fix.diff,raw_response.txt) underresults/<model>/<project>/Bug_<bug_id>/.
The LLM connectors live in llms/ (Google, OpenAI, OpenRouter, Ollama,
Copilot). LLMCommitAnnotator.py is a separate, reference usage of the same
connectors.
- Docker, with the
defects4j:3.0.1image available locally. Build it from the bundled context if needed:The second step takes seconds and makes Defects4J'sgit clone git@github.com:rjust/defects4j.git docker build -t defects4j:3.0.1 ./defects4j bash scripts/patchDefects4jImage.sh # re-run after any base rebuilddir-layout.csvcaches writable by the non-root uid the containers run as; without it Chart 26 fails withPermission denied(see scripts/README.md). - Python virtual environment with the dependencies installed:
python -m venv .venv source .venv/bin/activate pip install -r requirements.txt - A running LLM backend. By default the experiment uses Ollama:
Set
ollama serve # start the daemon ollama pull gpt-oss:20b # download the default model
OLLAMA_BASE_URLif Ollama is not onhttp://localhost:11434. Other providers can be selected via--model(they read their API keys from the environment):- Anthropic (Claude) —
--model claude-opus-4-8(oranthropic/<id>),ANTHROPIC_API_KEY. - OpenAI —
--model gpt-4o,OPENAI_API_KEY. - OpenRouter (default fallback) — any other model id,
OPENROUTER_API_KEY.
- Anthropic (Claude) —
- (Optional, for
--fixcheck) The FixCheck jar, built once from the vendoredfixcheck/sources:Needs Docker and network access (the Gradle wrapper downloads Gradle on first use). The script clonesbash scripts/buildFixcheck.sh
fixcheck/if missing and applies the local fixes fromscripts/fixcheck-patches/before building (see docs/fixcheck-verdict-limitations.md).
python Experiment.py --project Lang --bug-id 1 --workdir ./workspaceArguments:
| Argument | Description | Default |
|---|---|---|
--project |
Defects4J project name (e.g. Lang, Math). |
required |
--bug-id |
Numeric bug id (the b suffix is added automatically). |
required |
--workdir |
Host directory for the checkout (mounted as a volume). | required |
--model |
LLM model identifier. | ollama/gpt-oss:20b |
--temperature |
LLM sampling temperature. | 0.0 |
--max-tokens |
Maximum tokens to generate (Ollama num_predict). For a reasoning model this budget covers the chain of thought as well as the answer; reaching it is recorded as generation_status: truncated, never scored as the model failing. See docs/generation-budget.md. |
32768 |
--context-length |
Context window for prompt + generation (Ollama num_ctx). 131072 is gpt-oss:120b's native maximum; the daemon must serve at least this much, which scripts/ollama_serve.sh ensures by reading the same constant. |
131072 |
--include-test-code |
Include the failing trigger test method(s) in the prompt (extracted from the test file, not the whole file). | off |
--include-test-log |
Include the regression (trigger) test's isolated failure log in the prompt. | off |
--include-issue |
Include the original bug-tracker issue report in the prompt. | off |
--fixcheck |
Run FixCheck on plausible patches (applied and every trigger test passing) as an overfitting check. Requires the jar from bash scripts/buildFixcheck.sh. |
off |
--fixcheck-prefixes |
Number of input variations ("prefixes") FixCheck generates per trigger method. | 25 |
--fixcheck-assertions |
FixCheck's assertion-generation strategy: assert-true, previous-assertion, codellama, llama3.1, gpt-3.5, replit-code-llm, or ollama:<model>[@[<host>:]<port>] for any model an Ollama daemon serves (e.g. ollama:gpt-oss:120b@1995). previous-assertion keeps the trigger test's own assertions in every variation (restored by the patches in scripts/fixcheck-patches/ — background); assert-true only appends a vacuous assertTrue(true). The Ollama-backed ones generate new assertions with an LLM — see Ollama-backed assertion generators below. gpt-3.5 and replit-code-llm aren't wired up for this project's container/network setup yet. |
previous-assertion |
--fixcheck-inputs-class |
Force FixCheck's inputs-class (e.g. int, java.lang.String) instead of inferring it from the trigger test source. Also the way to run FixCheck on a trigger test the heuristic considers unmutable (see Not every bug is a FixCheck subject below). |
heuristic |
--fixcheck-similarity-threshold |
Minimum failure-similarity score (0-1) a FixCheck failing variation needs to mark the patch suspicious. | 0.8 |
--fixcheck-timeout |
Wall-clock budget in seconds for each FixCheck run (one per trigger class); 0 = unbounded. FixCheck runs its mutated prefixes with no timeout of its own, and an unbounded one used to hang until the per-bug timeout killed the whole run, losing a verdict already computed. A stop is recorded as timed_out and never affects fixed; result.json is written before FixCheck starts, so an interrupted FixCheck can only cost its own block. |
1800 |
--iteration |
Iteration index; when set, artifacts go to results/<model>/<project>/Bug_<bug_id>/<iteration>/ instead of results/<model>/<project>/Bug_<bug_id>/. Used by run_iterations.py. |
none |
FixCheck generates each test variation by replacing one literal of
inputs-class in the bug-revealing test, and it only draws that literal
from a statement that is not an assertion (see isAssertion in
fixcheck/src/main/java/org/imdea/fixcheck/transform/input/InputTransformer.java).
Many Defects4J trigger tests are nothing but assertEquals(...) lines — Lang
1's TestLang747 is one — and for those no inputs-class works at all:
upstream FixCheck dies with IllegalArgumentException: No locals of type <T>
and writes no report.
Experiment.py detects this up front (select_fixcheck_inputs returns no
usable type when the trigger methods have no mutable literal) and skips that
test class with an explanatory message instead of spending minutes on a run
that cannot produce anything. The fixcheck block still records the skip, and
analyzed_test_classes reports how many trigger classes actually yielded a
report — a suspicious: false verdict is only meaningful when that count is
above zero. Pass --fixcheck-inputs-class to force a run anyway.
Three related upstream behaviors are worth knowing about when picking subjects:
- Inherited trigger methods are invisible to FixCheck. It parses only the
named test class's own source file, so a trigger like Lang 10's
FastDateFormat_ParserTest::testLANG_831— inherited fromFastDateParserTest— yields no prefixes. Those classes are skipped too. - One unmutable method used to sink the whole class. FixCheck generates
variations for every method in
test-methodsand letsIllegalArgumentExceptionescapemain, so a single method without a literal ofinputs-classaborts the run before any report is written.Experiment.pytherefore passes only the methods it can actually mutate. - A prefix that fails to compile aborts the run.
PrefixRunnerrecords anullexecution result andFixCheck.generateSimilarPrefixesdereferences it, so the process dies with aNullPointerExceptionand writes no report at all — thenon_compilingcount inreport.csvis unreachable in practice. This is an upstream bug; the integration treats it as one more advisory failure.
An Ollama-backed generator asks a daemon to write each variation's assertions
with an LLM, instead of reusing the trigger test's own. It costs one model call
per prefix — on a local codellama:7b generation dominated the run at ~2 min
per prefix, 11 min for Lang 12's larger test — but it is the only way to get an
assertion the original test never made.
Any model, any port (recommended). ollama:<model>[@[<host>:]<port>] takes
both from the command line:
python Experiment.py --project Math --bug-id 69 --fixcheck \
--fixcheck-assertions ollama:gpt-oss:120b@1995ollama:gpt-oss:120b— port11434on localhostollama:gpt-oss:120b@1995— port1995on localhostollama:llama3.1:8b@gpu-box:11434— another host
The endpoint is separated with @ because a colon already belongs to Ollama's
own <model>:<version> tags. The model tag is used exactly as written (a bare
name resolves to <name>:latest, as Ollama itself does). This is
assertion/OllamaGenerator.java, added by
scripts/fixcheck-patches/0003-generic-ollama-assertion-generator.patch; it
prompts for bare assertion statements rather than a completed method, since
asking a reasoning model to "complete the code" gets back a whole method that
declares locals the prefix does not have.
The two legacy generators. --fixcheck-assertions codellama (or
llama3.1) still work, but both the endpoint and the model tag are
private final fields in
fixcheck/src/main/java/org/imdea/fixcheck/assertion/CodeLlamaOllama.java, so:
-
The endpoint is hardcoded to
http://localhost:11434. -
The model name is hardcoded to the bare tag (
codellama), which Ollama resolves tocodellama:latest. Havingcodellama:7bpulled is not enough. Alias it once:ollama pull codellama:7b ollama cp codellama:7b codellama:latest
Whichever is used, the daemon must be reachable from inside the container.
When it is on the host's loopback, Experiment.py starts the container with
network_mode="host" (needs_host_network); a generator naming a remote host
does not need that. The daemon and the exact tag are checked before the run
starts (check_ollama_backend), so a missing daemon or an unpulled tag
produces one clear message instead of an empty report for every trigger class.
LLM fix generation is non-deterministic — even at temperature=0, a large MoE
model served locally can produce a different patch on each call — so a single
run is not a reliable signal. run_iterations.py runs Experiment.py N times
per bug, wiping the checkout between runs to avoid contamination, storing each
run under results/<model>/<project>/Bug_<bug_id>/<iteration>/, and aggregating
each bug's outcomes into results/<model>/<project>/Bug_<bug_id>/summary.json
(plus a global summary printed at the end):
python run_iterations.py --project Lang --bug-id 1 --iterations 5--bug-id accepts several ids and inclusive ranges, running --iterations runs
for each bug — e.g. --bug-id 1-5 8 covers bugs 1, 2, 3, 4, 5 and 8:
python run_iterations.py --project Lang --bug-id 1-5 8 --iterations 10Iterations that are already done are skipped: if an iteration's
result.json already exists it is reused (marked skipped) instead of
re-running Experiment.py, so an interrupted run only recomputes the
outstanding work when resubmitted.
run_iterations.py mirrors Experiment.py's fix-generation flags (--model,
--temperature, --include-test-code, --include-test-log, --include-issue,
and the --fixcheck/--fixcheck-* flags) and forwards them to every run, so
they behave exactly as they do there. Its per-bug and global summaries, and
summary.json, also report a fixcheck_suspicious count alongside
applied/triggers_fixed/fixed.
To run every bug of one or more projects — the full 854-bug campaign — use
scripts/runCampaign.sh, which submits one SLURM job per project (all of
that project's bugs sequentially on its GPU, several projects in parallel):
./scripts/runCampaign.sh --projects JacksonXml,Csv,Codec # pilot: 40 bugs
./scripts/runCampaign.sh --projects all --minutes-per-bug 25 # all 854
./scripts/runCampaign.sh --projects all --dry-run # show, don't submitEach job runs run_project.py, which runs Experiment.py once per bug (as
opposed to run_iterations.py's N times), takes the bug list from Defects4J's
own active-bugs.csv so deprecated ids are never attempted, isolates per-bug
failures, enforces a per-bug timeout, deletes each checkout when it is done,
and resumes by skipping bugs that already have a result.json. It can also be
run directly, outside SLURM:
python run_project.py --project Lang # all 61 active bugs
python run_project.py --project Lang --bug-id 1,3-5 --dry-runsummarize_campaign.py aggregates the whole campaign out of results/:
python summarize_campaign.py --list-failuresSee scripts/README.md for the options and docs/campaign.md for the protocol.
--include-issue reads each bug's issue report from d4j/issues/<Project>/<bug_id>.txt,
downloaded once and committed, so a run needs no network and always sees the
same text:
python -m d4j.fetch_issues # all 854, skipping what is cached
python -m d4j.fetch_issues --project Lang --forceFetching at run time does not scale: unauthenticated api.github.com allows
60 requests/hour and the 280 GitHub-tracked bugs need two calls each, while
the archived Google Code pages another 197 bugs point at render through
JavaScript and cannot be scraped at all. fetch_issues.py handles all five
tracker families (Jira, GitHub, Google Code JSON, Google Code archive pages,
SourceForge). Put a GITHUB_TOKEN in .env before running it — with one, the
allowance is 5000/hour and the whole download takes minutes.
Only the reporter's original text reaches the prompt. The issue is cut at
the first Comment: line: the maintainers' thread is written after the bug
was diagnosed and routinely discusses (sometimes states) the fix, which would
leak the answer. 436 of the 813 issues with content have such a thread, and
dropping it removes 42% of all issue text without leaving a single issue empty.
The comments stay in the cache for inspection.
Not every bug has a usable issue, so result.json records an
issue_status saying which case it was: available (814 bugs), unusable
(the 22 SourceForge ones, whose pages scrape to a navigation menu rather than
the ticket — Chart 8, Time 14), empty (18 Chart bugs with no URL, plus one
Jsoup issue GitHub no longer has), or not-requested. Chart therefore
contributes no issue at all. See
docs/campaign.md.
Artifacts are written to results/<model>/<project>/Bug_<bug_id>/ (or
results/<model>/<project>/Bug_<bug_id>/<iteration>/ when --iteration is
set), where <model> is --model with any <provider>/ prefix stripped
(e.g. ollama/qwen3.6:35b → qwen3.6:35b):
-
prompt.txt— the exact prompt sent to the LLM. -
fix.diff— the unified diff produced by the LLM. -
raw_response.txt— the raw LLM response before diff extraction. -
result.json— run summary:applied,compiled_after,fixed,triggers_fixed, the bug'strigger_tests, anynew_failuresthe patch introduced, failing-test counts before and after, modified files, bug metadata, token usage, the raw LLM response, whether the regression test code/log/issue were included in the prompt (included_test_code,included_test_log,included_issue), and the FixCheck overfitting check's result (fixcheck,fixcheck_suspicious— see below).included_issuereflects whether an issue actually reached the prompt, andissue_statussays why when it did not (available/unusable/empty/fetch-failed/not-requested);fetch-failedis kept apart fromemptyso a rate limit is never recorded as "this bug has no issue".It also records why an empty patch is empty, which used to be unknowable:
generation_status(ok/truncated/context_exhausted/empty_response),done_reason,response_truncated,context_exhausted,reasoning_chars, and the SEARCH/REPLACE bookkeepingblocks_parsed/blocks_applied/blocks_failed. Without these, a model that hit its token ceiling mid-answer is indistinguishable from one that had no fix — 46 runs of the first campaign were the former and were reported as the latter.unidentified_failing_linesandmasked_triggersflag a run whose verdict rests on failing-test lines the parser could not read.post_fix_testsis"completed","did_not_terminate"ornull(no patch applied). The patched suite runs under a budget of 5x the pre-fix suite's duration, clamped to 30-90 min (post_fix_test_budget); a patch that makes it outrun that is recorded as not fixed, withcompiled_aftermeasured by a separatedefects4j compilerather than assumed. Before this, such a patch ran into the per-bug timeout and the run left noresult.jsonat all.Re-running a bug never deletes the previous attempt: its directory is moved to
results/old/superseded/<UTC timestamp>/..., and a warning is printed when it held a verdict — a run with a verdict is the run of record and should not be re-run (see docs/campaign.md, "The verdict of record"). -
run_status.json— only written byrun_project.py: how the run itself went (statusofok/error/timeout,exit_code,seconds) alongside the outcome flags. This is what distinguishes "the model did not fix it" from "the run never completed", and what a resumed campaign consults. -
test_before.log/test_after.log— test suite output before and after the fix. -
apply.log— output of thegit applyattempts. -
regression_test.log— Defects4J'sfailing_testsfile content from running the regression (trigger) test(s) in isolation: one entry per failing test with the exception type, message, and full stack trace. Only written when--include-test-logis set. -
issue.txt— the fetched bug-tracker issue report; only written when--include-issueis set. -
fixcheck/<test-class>/— only written when--fixcheckran on a plausible patch (one subdirectory per trigger test class):report.csvandscores-failing-tests.csv(FixCheck's own output; seefixcheck/src/main/java/org/imdea/fixcheck/writer/ReportWriter.javaandPrefixWriter.javafor their exact format), the generatedpassing-tests/,failing-tests/andnon-compiling-tests/prefix sources, andfixcheck.log(FixCheck's console output).result.json'sfixcheckblock mirrors the same data in structured form: per-class parsed reports/scores,analyzed_test_classes,failing_prefixes,max_failure_similarity, and thesuspiciousverdict (a failing variation scored at or above--fixcheck-similarity-threshold).fixcheck_suspiciousis the top-level convenience boolean mirroring that verdict; both arenull/falsewhen--fixcheckwasn't set or the patch wasn't plausible enough to run it on. Checkanalyzed_test_classesbefore reading afalseverdict as evidence of correctness — see Not every bug is a FixCheck subject above.