hay · benchmark

Ranked grep, measured against everything else

Every code-search tool installed on the test machine, over 4 usable repositories (plus 1 thin self-check corpus), on one task: given a symbol, how far down the results is its declaration?

4repositories, each parsed for ground truth
112sampled queries, identical for every tool
8search tools, one referee
+0.46hay’s best MRR margin over ripgrep (95% CI whiskers below)

Read this before the numbers

hay is built to rank declarations first, and this benchmark's ground truth is the declaration. It measures the thing hay optimises for, so it should win — and a win here is weaker evidence than it looks. Read inferential margins only for invocations with an explicit stable-order contract; every unordered tool remains visible as a descriptive snapshot.

The project's other evaluation does not have this problem: real agent searches paired with the files the agent opened next, so the ground truth was not designed around the tool. It is also unreproducible by anyone else, because those transcripts are private. Neither evaluation is sufficient alone, which is why both exist.

The result in one picture

Each mark is a tool’s mean improvement over deterministic ripgrep --sort path on that repository, with a 95% bootstrap-interval whisker. A filled mark means both statistical tests agree the difference is real; a hollow mark means they disagree or the interval touches zero — exactly the cases the tables refuse to call detected. The vertical line is ripgrep itself.

How much each stable-order tool beats ripgrep --sort path, per repository Horizontal dot plot. Each mark is a stable-order tool's mean improvement in mean reciprocal rank over deterministic ripgrep --sort path, with a whisker showing the 95% bootstrap interval. The vertical line at zero is ripgrep itself. 0.0 +0.1 +0.2 +0.3 +0.4 +0.5 +0.6 ripgrep linux +0.39 openclaw +0.11 ripgrep +0.46 alamofire +0.34hay filled = both statistical tests agree
Read it as: further right = answers higher up the list than ripgrep. hay (circles) is favoured by construction here — the ground truth is a declaration ranking, which is what hay optimises. Only stable-order invocations enter this inferential chart; unordered tools remain visible as descriptive snapshots in the tables and first-page chart.

The first page of results

Agents read roughly ten results. Share of queries answered within them; bars for unordered tools are descriptive snapshots:

Share of queries answered within the first ten results Grouped horizontal bars per repository: the accent bar is hay; muted bars are ast-grep and codespelunker descriptive snapshots; ripgrep is the implicit baseline axis. 0% 50% 100% linux 100% 90% 93% openclaw 100% 87% 97% ripgrep 100% 91% 91% alamofire 100% 79% 86%

What speed costs

Full-repository search time relative to ripgrep --sort path — geometric mean over complete paired queries, log scale. Ranking costs work; the question is how much answer quality buys:

Full-search time relative to ripgrep --sort path, geometric mean Bars from the one-times line: to the left is faster than ripgrep --sort path, to the right slower, on a logarithmic scale. Every displayed tool completed the same paired queries; tools with any timeout or missing result are omitted. 0.25× 0.5× ← fasterslower → ugrep 0.19× git-grep 0.38× hay 0.40× ag 0.59× ast-grep 1.03× cs 1.47× geometric mean over 12 complete paired queries · tools with timeouts omitted · lower is faster

The whole thing from zero

No jargon, one idea at a time. (The long version, with the traps and the wrong turns, lives in BENCHMARK_FEYNMAN.html.)

1 · One sentence

You type a word into a code-search tool. How far down the list is the thing you were looking for? Everything on this page is the consequence of trying to answer that honestly.

2 · The amnesiac librarian

Imagine a librarian who is fast, tireless, and has complete amnesia every morning. Ask her “where do we handle refunds?” and she walks the shelves looking for the word refund, then hands you every matching book — in shelf order. Not “most useful first”. Shelf order.

That librarian is grep, and every tool like it. Not stupid — prior-free: nothing ever gave it an opinion about which result is likelier to be the one you wanted. A coding agent reads the first page of results and acts on what it sees, so for agents the position of the answer is not cosmetic. It is the whole product.

hay is the same librarian with exactly one added opinion: a book that defines a thing is probably more useful than a book that merely mentions it. This benchmark asks whether that one opinion is worth anything.

3 · Grading without self-grading

To score an answer you must already know the right answer, for every question — normally a funded project with human annotators. The tempting wrong idea is to ask hay what the answer is: the tool would grade its own homework. Instead the answers come from a parser (ast-grep reads source like a compiler): which symbols are declared exactly once in this repository, and in which file? Different mechanism from any tool being tested, so no circularity — and “exactly once” because a symbol declared in three places has no single right answer at all.

4 · Turning position into a number

Averaging raw positions is wrong, because the gap between 1st and 2nd is enormous and the gap between 50th and 51st is nothing. So flip it — score by 1 divided by the position:

position12410100never
score1.000.500.250.100.010.00

Average that across queries and you have MRR — mean reciprocal rank. It falls exactly the way the experience falls.

5 · Same questions, then shuffle

Every tool runs on identical queries, so hard questions cancel out — you weigh yourself on the same scale before and after. For an invocation that guarantees stable order, each per-query difference is then resampled ten thousand times (the bootstrap), and Fisher's randomization test asks the same question by flipping signs at random. A difference is claimed only when both methods agree. Tools without a stable-order contract remain visible as descriptive snapshots; running statistics over a scheduler accident would manufacture precision.

6 · Two different ways to win

Searching ripgrep's own source for quiet: plain ripgrep returns 73 lines, hay returns 72, ast-grep returns 11 — and it is right to throw them away, because it parses the code and knows a comment or a string is not a use of the symbol. So: ast-grep filters, shrinking the list and keeping walk order; hay ranks, keeping everything but sorting the declaration first. Same median, different failure mode — when luck turns, ast-grep drops to 5th while hay is still 1st. That last part is a hypothesis, flagged as such, not a measured finding.

7 · The bug worth more than the benchmark

hay once scored below ripgrep on the Linux kernel. The mechanism was embarrassingly simple: hay spots declarations by keywords like fn, function, def — and C has none of them. Its core feature had never worked on C, proven by switching it off and watching the kernel score not move. Building the honest test found the bug. The fix is in every result below.

Method

Documentation track

Generated 2026-08-28 · hay hay 0.3.0 · rg ripgrep 15.2.0

Corpus revisions: linux 1b78070aaef6 · openclaw 0706f629c3b5 · ripgrep 3fce3b5bb023 · alamofire 0455bfb65089 · hay 87d3988c8119

A public development set for documentation retrieval: identifier-like tokens from ATX headings that occur in exactly one markdown file’s headings and in at least three parity-visible files. Ranks use the same 1000-result-line cap as the code track; cap truncations are reported as hay / rg, never absorbed into another metric.

corpusnMRR hayMRR rg --sort pathΔ MRR (95% CI)bootstrap prandomization pboth testscap truncations
linux60 0.0320.043 -0.010 [-0.037, 0.009] 0.4360.568 ✓ agree — not detected22 / 28
openclaw60 0.0600.132 -0.072 [-0.135, -0.009] 0.0240.026 ✓ agree — detected6 / 4
ripgrep60 0.1100.197 -0.087 [-0.148, -0.033] <0.0010.003 ✓ agree — detected0 / 1
alamofire60 0.1300.324 -0.194 [-0.276, -0.118] <0.001<0.001 ✓ agree — detected0 / 0
hay60 0.2280.313 -0.085 [-0.164, -0.010] 0.0240.033 ✓ agree — detected0 / 0

Query-shape splits

Mutually exclusive precedence: flag-shaped → uppercase → snake case → hyphenated → camel case → pascal case → plain word.

corpusfeaturenMRR hayMRR rg --sort pathΔ MRR
linuxflagShaped0 0.0000.000+0.000
linuxhyphenated4 0.0390.078-0.039
linuxsnakeCase4 0.1610.203-0.042
linuxupperCase0 0.0000.000+0.000
linuxcamelCase0 0.0000.000+0.000
linuxpascalCase29 0.0370.049-0.012
linuxplainWord23 0.0030.000+0.003
openclawflagShaped0 0.0000.000+0.000
openclawhyphenated6 0.0060.020-0.014
openclawsnakeCase1 0.0220.100-0.078
openclawupperCase2 0.2880.542-0.253
openclawcamelCase2 0.0570.177-0.120
openclawpascalCase18 0.0370.140-0.103
openclawplainWord31 0.0700.120-0.050
ripgrepflagShaped0 0.0000.000+0.000
ripgrephyphenated1 0.3330.017+0.316
ripgrepsnakeCase0 0.0000.000+0.000
ripgrepupperCase1 0.2500.143+0.107
ripgrepcamelCase0 0.0000.000+0.000
ripgreppascalCase17 0.1390.298-0.158
ripgrepplainWord41 0.0890.161-0.072
alamofireflagShaped0 0.0000.000+0.000
alamofirehyphenated0 0.0000.000+0.000
alamofiresnakeCase0 0.0000.000+0.000
alamofireupperCase2 0.1870.254-0.067
alamofirecamelCase1 0.5001.000-0.500
alamofirepascalCase51 0.1350.350-0.215
alamofireplainWord6 0.0120.021-0.010
hayflagShaped0 0.0000.000+0.000
hayhyphenated1 1.0001.000+0.000
haysnakeCase0 0.0000.000+0.000
hayupperCase1 0.1430.250-0.107
haycamelCase0 0.0000.000+0.000
haypascalCase12 0.2490.406-0.157
hayplainWord46 0.2080.275-0.067

linux

revision 1b78070aaef6 · clean · C · 95,911 files · 95,910 after gitignore · 96,013 tracked · 560,787 symbols declared once · 30 queries

toolMRRtop 10 median ranknever found Δ MRR vs rg --sort path (95% CI) randomization p
hay (this project) 0.933 100% 1 0% +0.392 [0.247, 0.544] <0.001
ast-grep (structural snapshot) 0.629 90% 1.5 0% snapshot only
ugrep --sort=name 0.606 90% 1.5 0% +0.065 [0.000, 0.163] 0.496
codespelunker (ranked) 0.604 93% 2 0% snapshot only
BSD grep 0.601 87% 2 0% snapshot only
the_silver_searcher 0.559 83% 2 3% snapshot only
ripgrep --sort path (deterministic baseline) 0.541 83% 2.5 0% baseline baseline
git grep 0.541 83% 2.5 0% +0.000 [0.000, 0.000] 1.000

snapshot only means the invocation has no stable-order contract: point estimates are shown, but confidence intervals and p-values are deliberately omitted.

Time to complete a full search

queryhayrg --sort pathugrep --sort=nameaggrepgit grepcsast-grep
su_remove2.66 s8.87 s1.52 s2.78 stimeout2.79 s10.30 s8.96 s
use_intel_pmu2.45 s8.72 s65 ms2.67 s19.47 s2.55 s11.32 s7.71 s
mgag200_init_pci_options2.70 s9.40 s1.90 s2.62 s15.96 s2.53 s9.94 s7.80 s
peak memory17 MB8 MB8 MB182 MB3 MB250 MB145 MB711 MB

openclaw

revision 0706f629c3b5 · clean · TS · 34,366 files · 33,882 after gitignore · 34,391 tracked · 21,385 symbols declared once · 30 queries

toolMRRtop 10 median ranknever found Δ MRR vs rg --sort path (95% CI) randomization p
hay (this project) 0.928 100% 1 0% +0.109 [0.035, 0.200] 0.017
ripgrep --sort path (deterministic baseline) 0.819 97% 1 0% baseline baseline
git grep 0.819 97% 1 0% +0.000 [0.000, 0.000] 1.000
codespelunker (ranked) 0.816 97% 1 0% snapshot only
ugrep --sort=name 0.797 97% 1 0% -0.022 [-0.080, 0.013] 1.000
ast-grep (structural snapshot) 0.776 87% 1 0% snapshot only
the_silver_searcher 0.730 87% 1 0% snapshot only
BSD grep 0.730 87% 1 0% snapshot only

snapshot only means the invocation has no stable-order contract: point estimates are shown, but confidence intervals and p-values are deliberately omitted.

Time to complete a full search

queryhayrg --sort pathugrep --sort=nameaggrepgit grepcsast-grep
collectScopes906 ms2.95 s1.06 s881 ms5.56 s883 ms2.75 s2.49 s
handleComposerKeydown844 ms2.79 s334 ms937 ms4.97 s884 ms2.70 s3.08 s
githubResponse1.10 s2.97 s1.06 s932 ms5.16 s889 ms2.63 s1.99 s
peak memory16 MB8 MB9 MB17 MB3 MB54 MB34 MB46 MB

ripgrep

revision 3fce3b5bb023 · clean · RUST · 236 files · 225 after gitignore · 237 tracked · 534 symbols declared once · 23 queries

toolMRRtop 10 median ranknever found Δ MRR vs rg --sort path (95% CI) randomization p
hay (this project) 0.877 100% 1 0% +0.458 [0.304, 0.613] <0.001
ast-grep (structural snapshot) 0.695 91% 1 0% snapshot only
codespelunker (ranked) 0.642 91% 2 0% snapshot only
the_silver_searcher 0.489 70% 4 0% snapshot only
ugrep --sort=name 0.421 70% 5 0% +0.002 [0.000, 0.007] 1.000
ripgrep --sort path (deterministic baseline) 0.419 70% 5 0% baseline baseline
git grep 0.419 70% 5 0% -0.000 [-0.000, 0.000] 1.000
BSD grep 0.418 70% 7 0% snapshot only

snapshot only means the invocation has no stable-order contract: point estimates are shown, but confidence intervals and p-values are deliberately omitted.

Time to complete a full search

queryhayrg --sort pathugrep --sort=nameaggrepgit grepcsast-grep
basic11 ms18 ms8 ms24 ms33 ms12 ms37 ms31 ms
enforce_literal_len11 ms19 ms9 ms20 ms41 ms12 ms36 ms36 ms
quiet14 ms28 ms8 ms23 ms28 ms12 ms41 ms43 ms
peak memory7 MB5 MB5 MB7 MB2 MB10 MB22 MB29 MB

alamofire

revision 0455bfb65089 · clean · SWIFT · 568 files · 555 after gitignore · 568 tracked · 509 symbols declared once · 29 queries

toolMRRtop 10 median ranknever found Δ MRR vs rg --sort path (95% CI) randomization p
hay (this project) 0.776 100% 1 0% +0.343 [0.235, 0.459] <0.001
ripgrep --sort path (deterministic baseline) 0.433 59% 3 0% baseline baseline
ugrep --sort=name 0.433 59% 3 0% +0.000 [0.000, 0.000] 1.000
git grep 0.433 59% 3 0% +0.000 [0.000, 0.000] 1.000
codespelunker (ranked) 0.430 86% 3 0% snapshot only
ast-grep (structural snapshot) 0.409 79% 3 7% snapshot only
the_silver_searcher 0.326 41% 12 10% snapshot only
BSD grep 0.294 38% 19 3% snapshot only

snapshot only means the invocation has no stable-order contract: point estimates are shown, but confidence intervals and p-values are deliberately omitted.

Time to complete a full search

queryhayrg --sort pathugrep --sort=nameaggrepgit grepcsast-grep
requestDidCancel16 ms31 ms7 ms34 ms139 ms15 ms63 ms30 ms
reset16 ms39 ms7 ms36 ms135 ms15 ms64 ms27 ms
value17 ms36 ms8 ms60 ms106 ms15 ms153 ms31 ms
peak memory11 MB6 MB4 MB7 MB3 MB13 MB44 MB26 MB

hay

Too few queries to conclude anything (1). Reported for completeness; these numbers are not evidence and no difference is marked as detected.

revision 87d3988c8119 · clean · RUST · 47,295 files · 91 after gitignore · 102 tracked · 59 symbols declared once · 1 query

toolMRRtop 10 median ranknever found Δ MRR vs rg --sort path (95% CI) randomization p
ast-grep (structural snapshot) 1.000 100% 1 0% snapshot only
hay (this project) 0.250 100% 4 0% +0.241 [0.241, 0.241] 1.000
codespelunker (ranked) 0.167 100% 6 0% snapshot only
the_silver_searcher 0.029 0% 35 0% snapshot only
ripgrep --sort path (deterministic baseline) 0.009 0% 111 0% baseline baseline
git grep 0.009 0% 117 0% -0.000 [-0.000, -0.000] 1.000
ugrep --sort=name 0.007 0% 139 0% -0.002 [-0.002, -0.002] 1.000
BSD grep 0.004 0% 237 0% snapshot only

snapshot only means the invocation has no stable-order contract: point estimates are shown, but confidence intervals and p-values are deliberately omitted.

Time to complete a full search

queryhayrg --sort pathugrep --sort=nameaggrepgit grepcsast-grep
write10 ms14 ms14 ms20 ms30.40 s12 ms17 ms19 ms
peak memory6 MB5 MB5 MB7 MB29 MB10 MB27 MB12 MB

What each tool is

toolranks resultsskips filesdeterministicmachine-readableindex
hayyesgitignoreyesrg-shapednone
rgnogitignoreyes (--sort path)yesnone
ugrepnoopt-inyes (--sort=name)yesnone
agnoown rulesnononone
grepnononenononone
git-grepnotracked onlyyesnogit
csyesown rulesno contractyesnone
ast-grepnogitignoreno contractyesparses per run

Not benchmarked

Limits

Reproduce it

# clones whatever benchmark.ts wants, runs --sample 30, renders this page, deletes its clones:
./benchmark-corpora.sh

# or, by hand:
BENCH_CORPORA="${XDG_CACHE_HOME:-$HOME/.cache}/hay/corpora"
mkdir -p "$BENCH_CORPORA"
git clone --depth 1 https://github.com/torvalds/linux.git       "$BENCH_CORPORA/linux"
git clone --depth 1 https://github.com/openclaw/openclaw.git     "$BENCH_CORPORA/openclaw"
git clone --depth 1 https://github.com/BurntSushi/ripgrep.git   "$BENCH_CORPORA/ripgrep"
git clone --depth 1 https://github.com/Alamofire/Alamofire.git   "$BENCH_CORPORA/alamofire"
cargo build --release --manifest-path hay/Cargo.toml
bun benchmark.ts --corpora "$BENCH_CORPORA" --sample 30
bun benchmark-report.ts             # writes BENCHMARK.md and this page