Skip to content

Round 3 ablation: real LLM-in-loop arm vs plain libFuzzer, + blog - #162

Merged
quinn-dougherty merged 2 commits into
mainfrom
parsifal/runs
Aug 24, 2026
Merged

Round 3 ablation: real LLM-in-loop arm vs plain libFuzzer, + blog#162
quinn-dougherty merged 2 commits into
mainfrom
parsifal/runs

Conversation

@quinn-dougherty

Copy link
Copy Markdown
Member

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. LibFuzzerRunner never passed -fork/-ignore_crashes, so -max_total_time never bound — every 300s window ended after 2–3 executions with cov: never printed. Measured on the real nushell-nu binary at an identical 30s budget:

execs exec/s cov crashes
before 3 0.0 0 1
after 2,127 70.9 1,516 243

This was asymmetric: baseline.py already passed fork flags 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 (fork_workers) so both arms are configured identically, and --no-fork works again instead of being silently overridden.

Fork mode's follow-on failures. The TimeoutExpired branch read only exc.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 also duration+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_results globbed only the top level, but llm-fuzz --reps N writes rep1/..repN/. The control found zero targets and would have fuzzed nothing while appearing to succeed. The same bug in load_sweep was worse — an empty baseline reads as 100% uplift. Both now glob rep*/, and the CLI errors instead of reporting zero.

Equal fuzz-seconds, honestly. Adds TSFuzzResult.elapsed_seconds (duration_seconds is 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.

  • Harness bugs counted as uplift — only the LLM arm writes a harness, so its own crashes land wholly in hybrid_only. reaches_target() now requires a grammar-source or runtime frame.
  • …but handler-only stacks are not harness bugs. typst's c6f014c21c9d has one and was found by both arms — and the control cannot produce harness bugs, which settles it. Now kept and tracked as unattributed. Replay 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. function_key() keys on symbols, not file: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.jsonl that has never been committed, so llm-fuzz -g nushell-nu,... died on KeyError. 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, exporting SSL_CERT_FILE (NixOS ships no default CA path for Python's OpenSSL, and survey/sources.py swallows the resulting CERTIFICATE_VERIFY_FAILED into an empty result). Control pins -j 1 deliberately — 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_tag leak — 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. ty reports 200 diagnostics, unchanged from the pre-existing baseline on a clean tree, none in touched files.
  • Fork fix: build any grammar and run LibFuzzerRunner(..., fork_workers=1).run(30) vs fork_workers=0; the latter reproduces the 2–3 exec / cov=0 pathology.
  • Rep discovery: targets_from_results on a dir laid out as rep1/<g>.jsonl returns targets (previously 0); baseline-compare on a dir with no JSONL at any level exits 1 instead of reporting zero bugs.
  • Filter: reaches_target() / is_handler_only() against the stored frames — gren's deep_let drops, nushell's scanner.c:116 keeps, typst's handler-only stack is unattributed rather than dropped.
  • Hash collapsing: function_key() returns the same key for scan_dollar_string_tag at :67 and :72.
  • End to end: ./homelab-treatment.sh then ./homelab-control.sh, then treesitter baseline-compare --hybrid results/treesitter-llm --baseline results/treesitter-baseline.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QnSQHg4FhSxEXRyM6Pf2Eo

quinn-dougherty and others added 2 commits August 24, 2026 08:24
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 quinn-dougherty changed the title fix(treesitter): make the LLM-vs-libFuzzer ablation measurable Round 3 ablation: real LLM-in-loop arm vs plain libFuzzer, + blog Aug 24, 2026
@quinn-dougherty
quinn-dougherty marked this pull request as ready for review August 24, 2026 15:41
@quinn-dougherty
quinn-dougherty merged commit 33fe569 into main Aug 24, 2026
1 check passed
@quinn-dougherty
quinn-dougherty deleted the parsifal/runs branch August 24, 2026 15:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant