Round 3 ablation: real LLM-in-loop arm vs plain libFuzzer, + blog - #162
Merged
Conversation
Running the tree-sitter uplift ablation end to end surfaced a chain of defects
that between them made the experiment unable to answer its own question. This
fixes them and adds the two run scripts used to execute both arms.
## The run-invalidating bug: libFuzzer stopped at the first crash
`LibFuzzerRunner` passed only -max_total_time/-max_len/-artifact_prefix. libFuzzer
aborts the process on the first crashing input, so -max_total_time never bound:
every 300s window ended after 2-3 executions with `cov:` never printed. The
treatment arm was not fuzzing at all, and its recorded fuzz-seconds were fiction.
Measured on the real nushell-nu binary at an identical 30s budget:
before execs=3 exec/s=0.0 cov=0 crashes=1
after execs=2127 exec/s=70.9 cov=1516 crashes=243
Critically this was ASYMMETRIC: baseline.py already passed -fork=1
-ignore_crashes=1 via extra_flags, so the control fuzzed properly while the
treatment stopped at bug #1. Any uplift number from that pairing would have
measured libFuzzer flags, not the LLM. Fork mode now lives on LibFuzzerRunner
itself (fork_workers), so both arms are configured identically by construction,
and baseline.py's --no-fork works again instead of being silently overridden.
Fork mode changes the parent's output format, so the stat:: parsers were extended
with fallbacks reading its progress lines (`#N: cov: .. oom/timeout/crash: o/t/c
time: Ns`), deriving exec/s from elapsed since the parent prints `exec/s: 0`.
## Fork mode's follow-on failure: lost windows
Two further defects, both exposed once windows ran long enough to time out:
- the TimeoutExpired branch read only exc.stdout, but libFuzzer writes to stderr,
so a timed-out window produced an EMPTY log and every parsed stat came back
None/0. The window then looked like a finding (cov=0, crashes=N) rather than a
failure. Now merges both streams.
- the safety margin was duration+60s; fork teardown with thousands of artifacts
outlasts it. Fork runs now get duration+300s.
6 of 75 treatment windows were lost to this before the fix landed.
## Budget matching was silently broken for --reps > 1
`targets_from_results` globbed `*.jsonl` at the top level only, but `llm-fuzz
--reps N` writes rep1/..repN/ subdirs. The control therefore found ZERO targets
and would have fuzzed nothing while appearing to succeed. Adds `rep_dirs()`, runs
one budget-matched control per rep (summing reps would over-grant the control
N-fold), and mirrors the rep layout into the output dir.
Same silent-empty bug existed in baseline_compare.load_sweep, where it was worse:
an empty *baseline* reads as 100% uplift. Now globs rep*/ too and the CLI errors
instead of reporting zero.
## Equal fuzz-seconds, honestly
- TSFuzzResult.elapsed_seconds records what a window really cost. duration_seconds
is only the configured cap and overstated every early-exiting window — the exact
mechanism that would have hidden the stop-on-first-crash bug from the budget
matcher. targets_from_results prefers elapsed, falling back for older records.
- The control now replays the treatment's WINDOW CADENCE (5x300s against a shared
corpus) rather than one long run. libFuzzer restarts re-seed from the corpus, so
a single continuous control would have confounded "the LLM rewrote the harness"
with "restarted 5 times". Coverage merges as a high-water mark, not a sum: the
windows share a corpus and summing would count the same edges repeatedly.
## Scoring: three ways the bug count was wrong
- Harness bugs counted as uplift. Only the LLM arm writes a harness, so a crash
in its own code lands entirely in hybrid_only and reads as discovery. gren's
`deep_let` overflow at harness.c:94 was a real instance. `reaches_target()` now
requires a frame in grammar source or the tree-sitter runtime.
- ...but handler-only stacks are NOT harness bugs. libFuzzer's deadly-signal path
often reports only its own frames. typst's c6f014c21c9d has such a stack and was
found by BOTH arms — and the control cannot produce harness bugs, which proves
the case. Those are now kept as findings and tracked separately as unattributed.
Replaying the input with ASAN_OPTIONS=fast_unwind_on_fatal=0 gives the true
stack: an assertion failure in ts_parser__external_scanner_serialize
(parser.c:409, `length <= 1024`) — a latent overflow the debug assert catches.
- One function reached from two of its own call sites hashed as two bugs, because
stack_hash includes line numbers. sql's scan_dollar_string_tag leak counted twice
(scanner.c:67 and :72). `function_key()` keys on symbols instead.
Net effect on the headline: 6 vs 4 with 2 hybrid-only became 6 vs 5 with 1.
## Registry
Adds the five ablation grammars (nushell-nu, gren, foam, typst, sql). They were
survey slugs resolvable only from a results/treesitter/registry.jsonl that has
never been committed and does not exist on a fresh machine, so `llm-fuzz -g
nushell-nu,...` died on KeyError. Static entries avoid ~1,960 GitHub API calls
(490 wiki grammars x 4 endpoints), which at the unauthenticated 60/hr limit is
~33h — a quota, not a burst limit, so backoff does not help.
Where the wiki lists competing forks the choice is pinned in the URL:
nushell/tree-sitter-nu and DerekStride/tree-sitter-sql per instruction;
uben0/tree-sitter-typst chosen on recency (2025-02 vs SeniorMars' 2023-04).
DerekStride commits no src/grammar.json, so the build runs `tree-sitter generate`
first — verified working.
## Run scripts
homelab-treatment.sh / homelab-control.sh, both using the nix-shell shebang so
the toolchain is implicit. They export SSL_CERT_FILE because NixOS ships no
default CA path for Python's OpenSSL, and survey/sources.py swallows the
resulting CERTIFICATE_VERIFY_FAILED into an empty result rather than erroring —
fetch_wiki_grammars() silently returned 0 grammars without it. .env is sourced
under `set -a` since the file has no `export` and nix-shell would not otherwise
inherit it. The control pins -j 1 on purpose: the treatment fuzzed serially on
one core, and concurrent control processes would each get less CPU than their
counterpart, biasing the comparison toward the LLM arm.
## Results (150 windows, ~12.5h, equal fuzz-seconds)
6 memory-safety bugs for the LLM arm vs 5 for plain libFuzzer; 1 hybrid-only.
The replicated finding is reliability rather than capability: on foam, gren and
nushell-nu the LLM arm found the known bug in 3/3 reps and the control in 1/3.
The single genuine capability instance is sql's scan_dollar_string_tag leak —
LLM 3/3 reps, control 0/3 across ~68k executions; reaching it needs structurally
valid $tag$...$tag$ input. Coverage is mixed, not a clean win: the LLM arm leads
on 4 of 5 grammars but the fixed template beats it on nushell-nu in both valid
reps, where it also finds the bug least often.
Sweep outputs are not included: scaffold/results/* is gitignored with no negation
for treesitter-llm/ or treesitter-baseline/.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QnSQHg4FhSxEXRyM6Pf2Eo
The post's own "confession" section admits the treatment/control comparison in
Round 2 was not an ablation: both arms were conventional fuzzers, because the
118-grammar survey had no LLM in its discovery loop. We have now run the
experiment it stood in for, and the headline claim needs revising.
## What the new run was
A real LLM-in-the-loop discovery arm (model writes the harness, fuzzes a 300s
window, reads coverage/crash feedback, rewrites, x5) against plain libFuzzer with
a fixed template harness on a matched window schedule and shared corpus. Five
grammars, three reps, 150 windows, 12.5 fuzzing-hours.
## What it found
6 memory-safety bugs for the LLM arm vs 5 for the control, 1 LLM-only. The
replicated effect is reliability rather than capability: on nushell-nu, gren and
foam the LLM arm found the bug in 3/3 reps and the control in 1/3 — bugs the bare
fuzzer demonstrably CAN find, but inconsistently at equal budget. The single
capability instance is sql's scan_dollar_string_tag leak: LLM 3/3, control 0/3
across ~68k executions, and reaching it needs valid $tag$...$tag$ input.
## Prose changes
- TL;DR: "nothing we ran supports the idea that an LLM in the fuzzing loop finds
more bugs" was true when written and is now false. Rewritten as mostly-but-not-
entirely negative, with the size of the effect stated up front so the revision
cannot be read as a reversal.
- "at no point did an LLM contribute to finding a bug" -> scoped to "up to this
point", since Round 3 supplies the counterexample.
- New "Round 3" section covering method, the two measurement bugs that had to be
fixed first (fork mode missing on one arm only; line numbers in the crash hash),
the reliability-vs-capability split, and the coverage result — including that
coverage and bug-finding come apart on nushell-nu, where the fixed template
reached more edges (5,111 vs 4,247) yet found the bug least often.
- Takeaway: the "niche where LLM discovery uplift has to live" is now reported as
existing but narrow, with the honest economic caveat that three fuzzer runs are
cheaper than three agent runs, so a consistency win is worth less than it looks.
Adds the methodological lesson: two implementation details were each independently
sufficient to produce a confident wrong answer in either direction, and the
apparatus has to be more trustworthy than the effect it measures.
- Notes the wall-clock cost the equal-fuzz-seconds framing hides: the LLM arm
needed ~30% more real time (~15h vs 12.5h of fuzzing) to spend the same budget.
- States the selection effect plainly: targets were chosen because the survey had
already found bugs there, so this measures re-discovery, and the sql leak may be
one the survey already had.
## Figures
- fig4-ablation (new): per-grammar reps-in-which-the-bug-was-found for both arms,
with the sql-only bug as a footnote. Generated from the sweep JSONL.
- fig1: the tree-sitter survey row was labelled "hybrid (LLM in loop)", which the
post's own confession contradicts. Relabelled "LLM-scaffolded survey (no LLM in
loop)", and the legend changed from "LLM in loop"/"plain libFuzzer" to "LLM
involved"/"no LLM at all" so a blue bar no longer asserts what the row denies.
- comms/blog/figures.py: generates fig4 from results/treesitter-{llm,baseline},
reusing the hand-authored figures' style constants, and rasterizes at 2x via
ImageMagick/librsvg to match the committed PNGs. fig1-fig3 stay hand-maintained;
their source sweeps are not on this machine.
Figure regeneration needs the sweep JSONL, which is gitignored (scaffold/results/*
has no negation for the treesitter-llm/ and treesitter-baseline/ dirs), so the
script cannot run from a fresh clone as-is.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QnSQHg4FhSxEXRyM6Pf2Eo
quinn-dougherty
marked this pull request as ready for review
August 24, 2026 15:41
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Executing the tree-sitter uplift ablation end to end surfaced a chain of defects that between them made the experiment unable to answer its own question. This fixes them and adds the run scripts for both arms.
libFuzzer stopped at the first crash.
LibFuzzerRunnernever passed-fork/-ignore_crashes, so-max_total_timenever bound — every 300s window ended after 2–3 executions withcov:never printed. Measured on the real nushell-nu binary at an identical 30s budget:This was asymmetric:
baseline.pyalready passed fork flags viaextra_flags, so the control fuzzed properly while the treatment stopped at bug #1. Any uplift number from that pairing would have measured libFuzzer flags, not the LLM. Fork mode now lives onLibFuzzerRunner(fork_workers) so both arms are configured identically, and--no-forkworks again instead of being silently overridden.Fork mode's follow-on failures. The
TimeoutExpiredbranch read onlyexc.stdout, but libFuzzer writes to stderr — a timed-out window produced an empty log and looked like a finding (cov=0, crashes=N) rather than a failure. The safety margin was alsoduration+60s, which fork teardown outlasts. 6 of 75 treatment windows were lost before these landed.Budget matching was silently broken for
--reps > 1.targets_from_resultsglobbed only the top level, butllm-fuzz --reps Nwritesrep1/..repN/. The control found zero targets and would have fuzzed nothing while appearing to succeed. The same bug inload_sweepwas worse — an empty baseline reads as 100% uplift. Both now globrep*/, and the CLI errors instead of reporting zero.Equal fuzz-seconds, honestly. Adds
TSFuzzResult.elapsed_seconds(duration_secondsis only the configured cap and overstated every early-exiting window — the exact mechanism that would have hidden the bug above from the budget matcher). The control now replays the treatment's window cadence (5×300s on a shared corpus) rather than one long run, since libFuzzer restarts re-seed from the corpus and would otherwise confound "the LLM rewrote the harness" with "restarted 5×".Three scoring corrections.
hybrid_only.reaches_target()now requires a grammar-source or runtime frame.c6f014c21c9dhas one and was found by both arms — and the control cannot produce harness bugs, which settles it. Now kept and tracked asunattributed. Replay withASAN_OPTIONS=fast_unwind_on_fatal=0gives the true stack: an assertion failure ints_parser__external_scanner_serialize(parser.c:409,length <= 1024) — a latent overflow the debug assert catches.function_key()keys on symbols, notfile:line.Net effect on the headline: 6 vs 4 with 2 hybrid-only → 6 vs 5 with 1.
Registry. Adds the five ablation grammars. They were survey slugs resolvable only from a
registry.jsonlthat has never been committed, sollm-fuzz -g nushell-nu,...died onKeyError. Static entries avoid ~1,960 GitHub API calls (490 wiki grammars × 4 endpoints) — ~33h at the unauthenticated 60/hr limit, which is a quota, not a burst limit, so backoff does not help.Run scripts.
homelab-treatment.sh/homelab-control.sh, nix-shell shebang, exportingSSL_CERT_FILE(NixOS ships no default CA path for Python's OpenSSL, andsurvey/sources.pyswallows the resultingCERTIFICATE_VERIFY_FAILEDinto an empty result). Control pins-j 1deliberately — the treatment fuzzed serially on one core, so concurrent control processes would bias the comparison toward the LLM arm.Results (150 windows, ~12.5h, equal fuzz-seconds)
6 memory-safety bugs for the LLM arm vs 5 for plain libFuzzer, 1 hybrid-only.
The replicated finding is reliability, not capability: on foam, gren and nushell-nu the LLM arm found the known bug in 3/3 reps, the control in 1/3. The single genuine capability instance is sql's
scan_dollar_string_tagleak — LLM 3/3, control 0/3 across ~68k executions; reaching it needs structurally valid$tag$...$tag$input.Coverage is mixed, not a clean win: the LLM arm leads on 4 of 5 grammars, but the fixed template beats it on nushell-nu in both valid reps — where it also finds the bug least often.
Caveats: five grammars, one model, one target class. nushell-nu rep2 lost all 5 windows to the timeout bug (valid for bug counts, not coverage). Sweep outputs are not included —
scaffold/results/*is gitignored with no negation for the new dirs.Test plan
cd scaffold && uv run ruff check && uv run ruff format --check && uv run pytest— 1038 pass, ruff clean.tyreports 200 diagnostics, unchanged from the pre-existing baseline on a clean tree, none in touched files.LibFuzzerRunner(..., fork_workers=1).run(30)vsfork_workers=0; the latter reproduces the 2–3 exec /cov=0pathology.targets_from_resultson a dir laid out asrep1/<g>.jsonlreturns targets (previously 0);baseline-compareon a dir with no JSONL at any level exits 1 instead of reporting zero bugs.reaches_target()/is_handler_only()against the stored frames — gren'sdeep_letdrops, nushell'sscanner.c:116keeps, typst's handler-only stack is unattributed rather than dropped.function_key()returns the same key forscan_dollar_string_tagat:67and:72../homelab-treatment.shthen./homelab-control.sh, thentreesitter baseline-compare --hybrid results/treesitter-llm --baseline results/treesitter-baseline.🤖 Generated with Claude Code
https://claude.ai/code/session_01QnSQHg4FhSxEXRyM6Pf2Eo