A long-context code retrieval and reproduction benchmark.
CodeNeedle reproduces the benchmark from the YouTube video (see benchmark_plan.md):
stuff a large source corpus into an LLM's context, then ask it to reproduce
the first N lines of specific named functions verbatim. Measures positional
recall under long context, not just named-entity lookup.
If you follow the video and get different numbers, that's expected — and the repo's are the corrected ones. Issues reported by viewers turned up three defects, all since fixed. Scores from before these changes are not comparable with scores after them.
What was wrong Effect on the old numbers Blank lines counted as recalled (#4) 21.9% of all "matched" credit was whitespace. 26 function-scores were built entirely from blank lines. Re-indented lines counted as hallucinations (#4) 21% of reported hallucinations were correct content, indented differently. For several models it was nearly all of them. The JS prompt anchored on text that isn't there (#4) 5 of 16 jQuery targets were asked about via function <name>(, which doesn't exist forval: function( value ) {. Those five averaged 59% vs 79% for the rest — in every model tested.The third is the one worth understanding: the benchmark was measuring a defect in its own question, and charging it to the model. That was later confirmed by direct measurement — same model, same corpus, same scorer, with the prompt as the only variable (
analysis/ab_anchor.py):
group content recall, old prompt → new 5 targets whose anchor was fictional 79.0% → 97.2% (+18.2) 4 controls whose anchor was already valid 96.8% → 98.2% (+1.5) A ~12× larger effect on exactly the targets the fix addresses, no target worse on content, and an effect size matching the ~20-point gap seen across the stored runs. Measured on Qwen3.8-27B (Q8_0, llama.cpp, 131072 context); n is small, so read it as direction and magnitude rather than a precise figure. Depth in the file doesn't explain the gap (mean start line 5,261 vs 5,281), and it isn't one bad target (15-point gap remains with the worst one removed).
Two smaller corrections came with them: targets whose name and signature are duplicated are now excluded, because no prompt can identify them; and the pass threshold is a ratio rather than a fixed 8, since blank lines no longer inflate the denominator. On a 20-line all-code window that is exactly the original 8/20.
Thanks to @AliakseiLasevich, @tleschinski, @tcclaviger, @akierum, @mazar and @QuantForgeSoftware for the reports that surfaced these.
This project uses uv for Python environment management.
# Create a venv in .venv/ and install the deps from requirements.txt
uv venv
uv pip install -r requirements.txt
uv pip install -r requirements-dev.txt
uv run pytest # 106 tests, ~9s, no model or network needed
The suite pins the extractor against the real fixtures (every body line must
equal the actual source line at that number), covers the scoring policy,
multi-file corpora, chart generation, and runs the full pipeline against an
in-process mock server. smoke_test.py is retained as a dependency-free
alternative.
Run any project script via uv run (no source .venv/bin/activate needed):
uv run python bench.py run --corpus http_server --model qwen36-35b
uv run python analysis/visualize.py
uv run python smoke_test.py
If you'd rather activate the venv:
source .venv/bin/activate
python3 bench.py run --corpus http_server --model qwen36-35b
The rest of this README writes commands as python3 … for brevity — prepend
uv run if your venv isn't active.
Start interactive bash session with all dependencies already pre-installed
docker compose run --rm appNow you can use either uv run or python directly
Close interactive shell by pressing CTRL-d or typing exit plus RETURN
macOS/Windows note: the compose file uses
network_mode: hostso the container can reach a local LM Studio server atlocalhost:1234. On Docker Desktop this must be enabled first (Settings → Resources → Network → "Enable host networking"), or drop that line and pass--base-url http://host.docker.internal:1234to your runs.
# 1. (LM Studio only) make sure your model is loaded with enough context.
# Defaults can silently sit at 4K. Force-reload at 128K:
lms unload qwen3.6-35b-a3b
lms load qwen3.6-35b-a3b --context-length 131072 --gpu max -y
# 2. Pick a corpus + a model and run them (assumes .venv is active; otherwise prepend `uv run`):
python3 bench.py run --corpus http_server --model qwen36-35b
# 3. Result is auto-saved as results/<corpus>__<model>.json.
configs/
corpora/ what files to test, sample size — one TOML per corpus
models/ model identifier and per-model knobs — one TOML per model
fixtures/ source files to test against (jquery.js, http_server.py, …)
results/ JSON dumps from every run, auto-named <corpus>__<model>.json
tests/ pytest suite (see "Tests" above)
analysis/
visualize.py Plotly dashboard builder
charts/ generated HTML output (gitignored)
VIZ_README.md chart-by-chart explanation + how to extend
.secrets/ API keys for hosted endpoints (gitignored, perms 700)
bench/ package internals
bench.py CLI entry
The split is by axis-of-change. You rarely change which files to test, but you constantly compare different models — so an N×M comparison needs only N+M files, not N*M.
Don't put real keys in committed config files. The recommended workflow:
mkdir -p .secrets && chmod 700 .secrets
echo 'sk-...' > .secrets/openai.key
chmod 600 .secrets/openai.keyThen reference it from a model config:
# configs/models/gpt-5.5.toml
name = "gpt-5.5"
base_url = "https://api.openai.com"
api_key_file = ".secrets/openai.key" # path resolved from repo root
temperature = 1.0
max_tokens = 8000
reasoning_effort = "none"
use_max_completion_tokens = true.secrets/ and any *.key file are already in .gitignore. Verify with
git check-ignore -v .secrets/openai.key — you should see a match.
Alternatives: api_key_env = "OPENAI_API_KEY" (read from environment), or
api_key = "..." (literal — only for non-secret tokens like LM Studio's
"not-needed" placeholder).
Full hosted-model details and known per-API quirks:
configs/CONFIG_README.md → Hosted models.
Field-by-field reference for every TOML key, plus recipes for adding a new corpus or model, lives in
configs/CONFIG_README.md.
[files]
directory = "fixtures" # required — relative to repo root, or absolute
glob = "*.js" # required
limit = 1 # optional cap on matched files (sorted lexically)
[sample]
k = 16 # number of functions to test
seed = 42
primary_lines = 20 # widen for harder recall (novel_* uses 48)
min_code_lines = 0 # optional: skip prose-dominated targets
[scoring]
count_comments = true # comments/docstrings earn credit (blank lines never do)
relax_indent = false # strict verbatim matching for every modelShipped:
novel_16k,novel_64k,novel_128k— deterministic generated Python with opaque identifiers/constants; these are the primary model-quality corporahttp_server— public Python standard-library code; fast smoke/control corpusjquery— public ~280KB / ~80K-token JS; real-world long-context control
The public corpora are useful controls but may exist in model training data. Do not treat them as the headline leaderboard. The novel corpora are generated from recorded seeds. The same 18 high-entropy, 48-line target functions appear at matched relative positions in every size while distractor volume grows. Each has a similarly named neighboring decoy, and the targets are split evenly over three independent seeds. This makes the suite harder while keeping context-length comparisons paired.
Regenerate or verify them deterministically:
python3 tools/generate_novel_corpora.py
python3 tools/generate_novel_corpora.py --checkTheir paired target names, full hashes, sizes, and function counts are recorded
in fixtures/novel/manifest.json; every result dump also records the exact
corpus hash. Rotate the generator seed/version for future model generations
once the checked-in fixtures may have entered training data.
If glob matches multiple files, they're concatenated with comment-marker
headers (# ====== path ====== / // ====== path ======) so the model sees
file boundaries. Cross-file name collisions are deduplicated (first occurrence
wins), and the prompt qualifies by file path when more than one file is in play.
name = "qwen3.6-35b-a3b" # required (model id the server knows)
base_url = "http://localhost:1234"
api_key = "not-needed" # optional
temperature = 0.0
max_tokens = 6000 # leave room for reasoning models
timeout = 600.0
suppress_thinking = true # appends /no_think (harmless when ignored)Shipped (see configs/models/ for the full set):
qwen3-4b-2507— small, honors/no_think, lowmax_tokensis fineqwen36-35b— reasoning-on-by-default; ignores/no_thinkandreasoning_effort, butprefill_no_think = trueskips CoT reliably, somax_tokens=1500is plentygemma-4-31b-4bit/-bf16— non-reasoning; needsstopsequences because it parrots the prompt back. Gemma normalizes leading whitespace, which is reported separately but does not change its scoring policy.qwen36-27b-mlx-4bit/-8bit— same weights, same runtime, quant is the only difference: the controlled comparison for "does quantization hurt recall". Their LM Studio ids (qwen3.6-27b/qwen3.6-27b-mlx) misleadingly imply GGUF-vs-MLX, so both configs set an explicitlabelfor charts.gpt-5.5,claude-sonnet-4-6— hosted; keys read from.secrets/
If you pass --model FOO and there's no matching config file, FOO is treated
as a raw model identifier with sane defaults — so you don't have to write a
config to do a one-off run, but for repeated use it's worth pinning the knobs.
Every run invocation needs one corpus (--corpus NAME or --file PATH)
and one model (--model NAME). They're resolved independently and stitched
together — there is no shared parent file or inheritance.
Resolution order, for both flags:
- If the value points to an existing file on disk, load it.
- Otherwise look it up by name under
configs/corpora/<name>.tomlorconfigs/models/<name>.toml. - (
--modelonly) If still not found, treat the value as a raw model identifier and use built-in defaults. A note is printed so you know the fallback was taken.
Override layering, applied in order (later wins):
- defaults baked into the loader (
max_tokens=6000,temperature=0, …) - fields set in the model config file
- CLI overrides —
--base-url,--max-tokens,--temperature,--timeout,--api-key - sampling overrides (
-k,--seed) layer over the corpus config's[sample]the same way - scoring overrides (
--relax-indent,--strict-indent,--no-comments,--count-comments) layer over the corpus's[scoring]section; use the same override for every model you intend to compare
This means model knobs can come from anywhere on the chain. A typical config
sets the model-specific defaults (e.g. max_tokens=6000 for a reasoning model)
and you override per-run knobs (--max-tokens 8000 for a hard case) without
editing the file.
--think flips one bit: it inverts suppress_thinking so chain-of-thought
is left on. Useful when you specifically want to compare reasoning vs.
no-reasoning recall on a model that supports both.
Output filename is results/<corpus.name>__<model.name>.json, where each
name is the config stem (filename without .toml). Raw-model fallback
sanitizes the identifier (/ → _). Override the whole path with --dump.
Mental model: corpus = what to ask, model = who to ask and how. Keep them orthogonal.
# Run a benchmark
python3 bench.py run --corpus http_server --model qwen36-35b
# Compare models on the same corpus
python3 bench.py run --corpus jquery --model qwen3-4b-2507
python3 bench.py run --corpus jquery --model qwen36-35b
# Override anything from the CLI
python3 bench.py run --corpus jquery --model qwen36-35b -k 8 --max-tokens 8000
# Test only specific functions (skips sampling)
python3 bench.py run --corpus http_server --model qwen36-35b \
--function is_cgi --function translate_path
# Use a raw model identifier (no config file needed)
python3 bench.py run --corpus http_server --model "qwen/qwen3-4b-2507"
# Single-file mode (no corpus config)
python3 bench.py run --file fixtures/http_server.py --model qwen36-35b
# See what would be tested
python3 bench.py extract --corpus http_server # sampled
python3 bench.py extract --corpus http_server --all # every extractable function
python3 bench.py extract --corpus http_server --show is_cgi # ground truth
# Re-score a prior dump without re-querying
python3 bench.py rescore results/http_server__qwen36-35b.json
# Build Plotly dashboards comparing every run in results/
python3 analysis/visualize.py
# -> analysis/charts/index.html + analysis/charts/<corpus>/<chart>.html
# (see analysis/VIZ_README.md for what each chart shows)
Supported source languages: .js, .mjs, .cjs (esprima), .py (ast).
Per-function diff uses colors matching the video:
- gray — matched line (expected + produced at correct position)
- orange — expected but missing from the output
- yellow — hallucinated / mangled line
- blue/cyan — extra correct lines past the configured primary window (bonus)
- dim — correct, but not eligible for credit (see below)
Pass threshold per function: ≥ 40% of the scored lines matched. On a 20-line all-code window that's exactly the video's 8-of-20.
A recall window is rarely all code. In the shipped corpora:
| corpus | code | blank | comment | docstring |
|---|---|---|---|---|
http_server |
40% | 17% | 5% | 38% |
jquery |
63% | 19% | 18% | — |
novel_* |
96% | 0% | 2% | 2% |
So the policy matters:
- Blank lines never earn credit. Reproducing whitespace demonstrates no recall, and blanks were ~18% of every window. They're excluded from both the numerator and the denominator. They still take part in the alignment, so a model that puts them in the right places stays in positional sync — only the accounting changes.
- Comments and docstrings count by default. Verbatim prose can't be
inferred from surrounding code, so retrieving it is genuine recall. But it
isn't code recall, so every result reports the split:
Pass
=== guess_type [PASS] matched=12/16 (75%) hallucinated=0 bonus=0 === composition: code 6/8 · prose 6/8 · 4 blank skipped--no-commentsto score code only.http_server.log_messagehas just one code line in its window — under the default policy it can pass on docstring recall alone, which thecode=0/1column makes obvious. - Prose-dominated targets are flagged, and
--min-code-lines N(or[sample] min_code_linesin a corpus config) drops them from the sample entirely. Default is 0 — no filtering — so existing results stay reproducible.
Re-score any past run under a different policy without re-querying:
python3 bench.py rescore results/http_server__gpt-5.5.json --corpus http_server --no-comments
A run that fail-fasts records "complete": false plus the query counts. Charts
label it ⚠ INCOMPLETE and outline the bar in red, and run-missing.py
re-runs it instead of treating the file's existence as success. A run that
attempted every query but contains request errors is likewise invalid and must
be rerun. Leaderboard quality percentages exclude errored queries, clearly mark
the whole run ineligible, and rank it after valid runs. The leaderboard
plots percentages, not raw line counts, so a run with a smaller denominator
isn't misread as a worse model.
Historical results from an unknown or older benchmark generation are also
treated as stale. New dumps record explicit prompt and scorer versions;
analysis/visualize.py refuses to chart a dump unless both match the current
implementation, and run-missing.py schedules it for a fresh run. This is
intentional: a dump-schema number cannot prove that two models saw the same
prompt or were scored under the same rules.
The results dump is rewritten after every query, atomically, so a crash,
an OOM kill or a Ctrl-C costs you only the query in flight rather than the
whole run — which on an 80K-token corpus can be half an hour of inference. A
dump written mid-run carries "in_progress": true; run-missing.py re-runs
it and the charts flag it, so a partial run is never mistaken for a result.
Each dump records the full request shape (model, temperature, token budget,
reasoning knobs, stop sequences), the sampling parameters (k, seed, filters),
the scoring policy, explicit prompt/scorer generation identifiers, and a hash
of the exact corpus text. Server-side settings
the API can't report — KV-cache quantization, loaded context length, quant
build — should be recorded by hand:
python3 bench.py run --corpus jquery --model qwen36-35b \
--notes "LM Studio 0.3.x, Q8 KV cache, 131072 ctx, Unsloth Q4_K_XL"
The prompt quotes the target's own signature text, copied verbatim from the corpus:
Task: reproduce verbatim the first 20 lines of the body of the function named `val` ...
It is the function introduced by exactly this text:
val: function( value ) {
Output the 20 lines that come immediately after it.
Earlier versions instead told the model to look for function <name>(. That
text does not exist for property- or assignment-style definitions — 5 of the 16
sampled jQuery targets (PSEUDO, init, then, val, parseHTML) are
written as val: function( value ) { or jQuery.parseHTML = function(...), so
the model was pointed at a string the file never contains. Across the stored
runs those five averaged 59% against 79% for the rest — a gap present in
every model tested, and not explained by their depth in the file. The benchmark
was measuring a defect in its own question.
Unanswerable targets are excluded. If a signature is not unique in the file, no prompt could single that definition out, so the target is dropped from sampling and the exclusion is printed:
excluded 4 unanswerable target(s) — duplicate name AND identical signature: ID, get, setup, sortOrder
jQuery declares 11 eligible names more than once; quoting the signature
disambiguates 10 of them, because the signatures differ even where the names do
not (PSEUDO: function( match ) vs PSEUDO: function( pseudo, argument )).
Only the genuinely identical ones are dropped.
A line the model reproduced correctly but indented differently is not a hallucination. It is reported separately:
=== val [PASS] matched=19/20 hallucinated=0 reindented=1 bonus=0 ===
-- reproduced, but re-indented (not hallucinations) --
var hooks, ret, valueIsFunction,
Scoring is unchanged: strict matching means verbatim, so a re-indented line
still counts as a miss and pass/fail verdicts stay comparable with earlier
runs. What changed is the label — such a line no longer inflates the
hallucinated count, and it is no longer penalized twice (once as a missing
expected line, once as a hallucinated emitted line).
If you care about content rather than exact whitespace, score by content:
python3 bench.py run --corpus jquery --model <model> --relax-indent
python3 bench.py rescore results/jquery__<model>.json --corpus jquery --relax-indent
For a comparable content-normalized leaderboard, set relax_indent = true
under [scoring] in a separate corpus config and run every model against
that corpus. Scoring policy cannot be set in a model config: allowing Gemma, for
example, to ignore indentation while its peers are judged strictly would make
the ranking invalid. The dashboard keeps non-default scoring policies in
separate cohorts.
For fair comparison matching the video:
- llama.cpp:
--ctx-size 131072 --cache-type-k q8_0 --cache-type-v q8_0, prompt caching on (default in recent builds). - LM Studio: set context length to cover the file, enable "KV cache quantization" → Q8. Prefix cache is automatic.
- Ollama: set
num_ctxvia Modelfile or per-request; no KV quant yet, so comparison isn't apples-to-apples.
Keep temperature at 0. Default max_tokens=6000 to leave room for reasoning models.
Colour is on for terminals and off when output is piped or redirected, so anything you copy out of a log is plain text. Override either way:
python3 bench.py --no-color run --corpus http_server --model <model> # never colour
python3 bench.py --color rescore results/<run>.json --corpus jquery # force colour
NO_COLOR=1 and FORCE_COLOR=1 are honoured too (no-color.org).
On Windows, ANSI processing is enabled for the console when possible; if it
cannot be enabled, colour is disabled rather than printing escape codes as
literal text (←[32m✓←[0m).
All files are read and written as UTF-8 explicitly, and stdout/stderr are
reconfigured to UTF-8 at startup. Without that, Python falls back to the locale
encoding — cp1252 on most Windows installs — which can't represent the ← in
the chart navigation, the ≥/⚠/✓ in the console report, or the CJK
characters inside the bundled plotly.min.js. Symptoms this prevents:
UnicodeEncodeError: 'charmap' codec can't encode character '←'
Piping output to a file (python bench.py run … > run.log) is covered too —
that's the case where Windows drops to the locale encoding even though an
interactive console would have coped.
lms pslies about context size after JIT loads. If large prompts fail with a 400 "context length" error despitelms psshowing a big number, force-reload:lms unload <model> lms load <model> --context-length 131072 --gpu max -y- Auto-unload by idle TTL (default ~60 min). After it expires, the next request triggers a JIT reload at default settings, silently dropping your large context. Either disable TTL in the LM Studio UI or re-load before each session.
- Reasoning models (qwen3.5, qwen3.6, …) mostly ignore
/no_thinkandreasoning_effort, butprefill_no_think = truedoes work — it seeds an empty<think></think>assistant turn so the model continues past it. That's why the shipped qwen configs run atmax_tokens=1500rather than 6000. See the full matrix inconfigs/CONFIG_README.md. If a model honors none of the three, give it budget for the chain-of-thought plus the answer — bump to 8000+ if responses come back empty.
benchmark_plan.md— analysis of what the benchmark measures and whybench.py— CLI entrybench/config.py— TOML config loaderbench/extract.py— function extraction + multi-file source aggregationbench/client.py— tiny OpenAI-compatible clientbench/scorer.py— LCS alignment, line classification, pass/failbench/report.py— ANSI color renderingbench/runner.py— orchestration: prompt assembly, query, score, dumpanalysis/visualize.py— builds Plotly HTML dashboards fromresults/*.json(seeanalysis/VIZ_README.mdfor chart-by-chart details)smoke_test.py— end-to-end sanity check without an LLM