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?
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.
The first page of results
Agents read roughly ten results. Share of queries answered within them; bars for unordered tools are descriptive snapshots:
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:
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:
| position | 1 | 2 | 4 | 10 | 100 | never |
|---|---|---|---|---|---|---|
| score | 1.00 | 0.50 | 0.25 | 0.10 | 0.01 | 0.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
- Ground truth — ast-grep (a parser), independent of every heuristic under test. A symbol declared in more than one place is discarded: with no single right answer, scoring against an arbitrary one is noise.
- Queries — symbols with 5–2,000 occurrences, sampled with a fixed seed so the same corpus yields the same queries every run.
- Metric — reciprocal rank of the first result line in the declaring file, capped at 1000.
- Difference — only for stable-order invocations: absolute, against deterministic ripgrep
--sort path, 95% paired bootstrap interval over per-query reciprocal ranks, 10,000 replicates, fixed seed. Unordered tools remain descriptive snapshots. An interval spanning zero is not a detected difference. - Flag parity — every tool asked for the same job. Filtering is deliberately not normalised across tools: what a tool skips is a real property of it, so the file counts it sees are reported instead. Ripgrep gets
--sort path --no-ignore-dot --no-ignore-global --no-ignore-excludeso its order is deterministic and it walks exactly whathaywalks; ugrep gets its documented--sort=name. Without these controls, the head-to-head changes with traversal scheduling instead of ranking quality. - Invocation — absolute paths only. On this machine
grepis a shell function resolving to ugrep, so calling tools by name would have measured the wrong program.
Documentation track
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.
| corpus | n | MRR hay | MRR rg --sort path | Δ MRR (95% CI) | bootstrap p | randomization p | both tests | cap truncations |
|---|---|---|---|---|---|---|---|---|
| linux | 60 | 0.032 | 0.043 | -0.010 [-0.037, 0.009] | 0.436 | 0.568 | ✓ agree — not detected | 22 / 28 |
| openclaw | 60 | 0.060 | 0.132 | -0.072 [-0.135, -0.009] | 0.024 | 0.026 | ✓ agree — detected | 6 / 4 |
| ripgrep | 60 | 0.110 | 0.197 | -0.087 [-0.148, -0.033] | <0.001 | 0.003 | ✓ agree — detected | 0 / 1 |
| alamofire | 60 | 0.130 | 0.324 | -0.194 [-0.276, -0.118] | <0.001 | <0.001 | ✓ agree — detected | 0 / 0 |
| hay | 60 | 0.228 | 0.313 | -0.085 [-0.164, -0.010] | 0.024 | 0.033 | ✓ agree — detected | 0 / 0 |
Query-shape splits
| corpus | feature | n | MRR hay | MRR rg --sort path | Δ MRR |
|---|---|---|---|---|---|
| linux | flagShaped | 0 | 0.000 | 0.000 | +0.000 |
| linux | hyphenated | 4 | 0.039 | 0.078 | -0.039 |
| linux | snakeCase | 4 | 0.161 | 0.203 | -0.042 |
| linux | upperCase | 0 | 0.000 | 0.000 | +0.000 |
| linux | camelCase | 0 | 0.000 | 0.000 | +0.000 |
| linux | pascalCase | 29 | 0.037 | 0.049 | -0.012 |
| linux | plainWord | 23 | 0.003 | 0.000 | +0.003 |
| openclaw | flagShaped | 0 | 0.000 | 0.000 | +0.000 |
| openclaw | hyphenated | 6 | 0.006 | 0.020 | -0.014 |
| openclaw | snakeCase | 1 | 0.022 | 0.100 | -0.078 |
| openclaw | upperCase | 2 | 0.288 | 0.542 | -0.253 |
| openclaw | camelCase | 2 | 0.057 | 0.177 | -0.120 |
| openclaw | pascalCase | 18 | 0.037 | 0.140 | -0.103 |
| openclaw | plainWord | 31 | 0.070 | 0.120 | -0.050 |
| ripgrep | flagShaped | 0 | 0.000 | 0.000 | +0.000 |
| ripgrep | hyphenated | 1 | 0.333 | 0.017 | +0.316 |
| ripgrep | snakeCase | 0 | 0.000 | 0.000 | +0.000 |
| ripgrep | upperCase | 1 | 0.250 | 0.143 | +0.107 |
| ripgrep | camelCase | 0 | 0.000 | 0.000 | +0.000 |
| ripgrep | pascalCase | 17 | 0.139 | 0.298 | -0.158 |
| ripgrep | plainWord | 41 | 0.089 | 0.161 | -0.072 |
| alamofire | flagShaped | 0 | 0.000 | 0.000 | +0.000 |
| alamofire | hyphenated | 0 | 0.000 | 0.000 | +0.000 |
| alamofire | snakeCase | 0 | 0.000 | 0.000 | +0.000 |
| alamofire | upperCase | 2 | 0.187 | 0.254 | -0.067 |
| alamofire | camelCase | 1 | 0.500 | 1.000 | -0.500 |
| alamofire | pascalCase | 51 | 0.135 | 0.350 | -0.215 |
| alamofire | plainWord | 6 | 0.012 | 0.021 | -0.010 |
| hay | flagShaped | 0 | 0.000 | 0.000 | +0.000 |
| hay | hyphenated | 1 | 1.000 | 1.000 | +0.000 |
| hay | snakeCase | 0 | 0.000 | 0.000 | +0.000 |
| hay | upperCase | 1 | 0.143 | 0.250 | -0.107 |
| hay | camelCase | 0 | 0.000 | 0.000 | +0.000 |
| hay | pascalCase | 12 | 0.249 | 0.406 | -0.157 |
| hay | plainWord | 46 | 0.208 | 0.275 | -0.067 |
linux
| tool | MRR | top 10 | median rank | never 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 |
Time to complete a full search
| query | hay | rg --sort path | ugrep --sort=name | ag | grep | git grep | cs | ast-grep |
|---|---|---|---|---|---|---|---|---|
su_remove | 2.66 s | 8.87 s | 1.52 s | 2.78 s | timeout | 2.79 s | 10.30 s | 8.96 s |
use_intel_pmu | 2.45 s | 8.72 s | 65 ms | 2.67 s | 19.47 s | 2.55 s | 11.32 s | 7.71 s |
mgag200_init_pci_options | 2.70 s | 9.40 s | 1.90 s | 2.62 s | 15.96 s | 2.53 s | 9.94 s | 7.80 s |
| peak memory | 17 MB | 8 MB | 8 MB | 182 MB | 3 MB | 250 MB | 145 MB | 711 MB |
openclaw
| tool | MRR | top 10 | median rank | never 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 | — |
Time to complete a full search
| query | hay | rg --sort path | ugrep --sort=name | ag | grep | git grep | cs | ast-grep |
|---|---|---|---|---|---|---|---|---|
collectScopes | 906 ms | 2.95 s | 1.06 s | 881 ms | 5.56 s | 883 ms | 2.75 s | 2.49 s |
handleComposerKeydown | 844 ms | 2.79 s | 334 ms | 937 ms | 4.97 s | 884 ms | 2.70 s | 3.08 s |
githubResponse | 1.10 s | 2.97 s | 1.06 s | 932 ms | 5.16 s | 889 ms | 2.63 s | 1.99 s |
| peak memory | 16 MB | 8 MB | 9 MB | 17 MB | 3 MB | 54 MB | 34 MB | 46 MB |
ripgrep
| tool | MRR | top 10 | median rank | never 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 | — |
Time to complete a full search
| query | hay | rg --sort path | ugrep --sort=name | ag | grep | git grep | cs | ast-grep |
|---|---|---|---|---|---|---|---|---|
basic | 11 ms | 18 ms | 8 ms | 24 ms | 33 ms | 12 ms | 37 ms | 31 ms |
enforce_literal_len | 11 ms | 19 ms | 9 ms | 20 ms | 41 ms | 12 ms | 36 ms | 36 ms |
quiet | 14 ms | 28 ms | 8 ms | 23 ms | 28 ms | 12 ms | 41 ms | 43 ms |
| peak memory | 7 MB | 5 MB | 5 MB | 7 MB | 2 MB | 10 MB | 22 MB | 29 MB |
alamofire
| tool | MRR | top 10 | median rank | never 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 | — |
Time to complete a full search
| query | hay | rg --sort path | ugrep --sort=name | ag | grep | git grep | cs | ast-grep |
|---|---|---|---|---|---|---|---|---|
requestDidCancel | 16 ms | 31 ms | 7 ms | 34 ms | 139 ms | 15 ms | 63 ms | 30 ms |
reset | 16 ms | 39 ms | 7 ms | 36 ms | 135 ms | 15 ms | 64 ms | 27 ms |
value | 17 ms | 36 ms | 8 ms | 60 ms | 106 ms | 15 ms | 153 ms | 31 ms |
| peak memory | 11 MB | 6 MB | 4 MB | 7 MB | 3 MB | 13 MB | 44 MB | 26 MB |
hay
Too few queries to conclude anything (1). Reported for completeness; these numbers are not evidence and no difference is marked as detected.
| tool | MRR | top 10 | median rank | never 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 | — |
Time to complete a full search
| query | hay | rg --sort path | ugrep --sort=name | ag | grep | git grep | cs | ast-grep |
|---|---|---|---|---|---|---|---|---|
write | 10 ms | 14 ms | 14 ms | 20 ms | 30.40 s | 12 ms | 17 ms | 19 ms |
| peak memory | 6 MB | 5 MB | 5 MB | 7 MB | 29 MB | 10 MB | 27 MB | 12 MB |
What each tool is
| tool | ranks results | skips files | deterministic | machine-readable | index |
|---|---|---|---|---|---|
hay | yes | gitignore | yes | rg-shaped | none |
rg | no | gitignore | yes (--sort path) | yes | none |
ugrep | no | opt-in | yes (--sort=name) | yes | none |
ag | no | own rules | no | no | none |
grep | no | none | no | no | none |
git-grep | no | tracked only | yes | no | git |
cs | yes | own rules | no contract | yes | none |
ast-grep | no | gitignore | no contract | yes | parses per run |
Not benchmarked
- ack — excluded after the pinned 0.1.4 probe hit the 60 s cap on 52 of 113 rank queries and 5 of 13 full-search timings; mostly censored ranks cannot support comparison
- zoekt — trigram index server; needs a build step and a daemon, so it is a different product category
- Google codesearch (csearch) — trigram index; not installed on the test machine
- Sourcegraph, GitHub code search — hosted services, not comparable on a local tree
- LSP workspace symbols — requires a language server per language and a warm project
- embedding / semantic search — needs an index build and a model; the opposite of this tool's premise
Limits
- The task favours
hayby construction. Stated twice because it is the most important caveat here. - Definition-finding is not all of search. An agent also asks where something is used, what calls what, and where a behaviour lives with no symbol to name it. None of that is measured.
- Ground truth is a parser's opinion. Declaration forms the patterns miss are absent rather than wrong.
- Unordered tools are snapshots, not inference. Their MRR and top-10 values describe this run only; scheduler or traversal order may change them on the same immutable corpus.
- One machine, one filesystem, macOS.
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