koboldcpp/examples/llama-eval
Piotr Wilkin (ilintar) f3363593f4
Add more benchmarks to llama-eval (#26174)
* llama-eval : add sandboxed execution for code suites

Adds a lightweight replacement for the Docker containers usually used to run
generated code, so a benchmark can be executed without a daemon, an image or
root.

A cached venv is provisioned per suite and reused; candidate programs run in a
throwaway working directory as their own process group, under the strongest
isolation the host offers:

  bwrap    read-only root, tmpfs /tmp and $HOME, no network
  unshare  no network, via --map-current-user
  rlimit   cpu/memory/file-size/process caps only

Candidate code must not run as uid 0, which is why "unshare -r" is a
last-resort tier that warns: root bypasses file permission bits, so a test
asserting that a write to a chmod 0444 file fails would instead see it succeed.

Two details worth noting for the bwrap tier. Only paths hidden by the $HOME
tmpfs are bound back, because bwrap cannot mount onto a symlink and
version-manager alias directories usually are one. And the venv's base
interpreter tree is resolved by walking the symlink chain, since a
version-managed interpreter is commonly reached through an unversioned alias
that startup resolves through.

Suites can declare provisioning steps, which run once at build time while the
network is still up, so test time never needs it.

Assisted-by: Claude Opus 4.8 <noreply@anthropic.com>

* llama-eval : add HumanEval and ClassEval code suites

Adds two execution-graded suites alongside the existing text-graded ones. The
model's reply is parsed for a python code block, combined with the task's own
unit tests, and run in the sandbox.

  --dataset humaneval   164 function-level problems, scored pass@k
  --dataset classeval   100 class-level tasks, scored at class level (feeding
                        pass@k) and method level, matching upstream

pass@k reuses the existing chunk mechanism: --n_cases at a multiple of the
dataset size gives that many samples per problem, aggregated with the unbiased
estimator from the Codex paper.

Program construction and result interpretation are dataset-owned hooks, because
the two suites differ: one HumanEval problem is a function judged by a single
assert, whereas one ClassEval task is a class judged by many independent test
methods, and reducing that to pass/fail would throw away partial credit. Suites
also declare their own package set, provisioning steps, interpreter and timeout.

The package set is derived from the dataset's own imports rather than a
requirements file. HumanEval turns out to need no third-party packages at all;
ClassEval needs thirteen, two of which must be declared because no import
mentions them -- lxml is selected by string in ClassEval_44, and scipy is a
gensim requirement.

Notes on ClassEval specifically:

* gensim publishes no wheel past cp313, so 3.13 is preferred; on 3.14+ the two
  pure-Python APIs it uses are provided by a small shim instead.
* The three nltk corpora it needs are pre-seeded at venv build time. These are
  the modern names -- the dataset asks for the since-renamed punkt and
  averaged_perceptron_tagger.
* It defaults to a patched fork, ilintar/ClassEval, because three upstream
  tests cannot be passed by anything: a hard-coded 2024 date checked against
  datetime.now(), an assertion on the dataset author's own hostname, and an
  exact float comparison. Reference solutions go from 96/100 to 100/100 classes
  and 2182/2196 to 2196/2196 test methods. No skeleton is touched, so what the
  model is asked to write is unchanged; --dataset-source selects the original.

--exec-seed (default 0) seeds the RNG before each test, so suites whose tests
draw random inputs score reproducibly. ClassEval_58 fails its own reference
solution about 30% of the time without it.

The simulator gains both suites, so the harness can be exercised without a
model.

Assisted-by: Claude Opus 4.8 <noreply@anthropic.com>

* llama-eval : fix silent isolation downgrade, generalise the sandbox runner

The isolation probe ran the interpreter without binding it back, so whenever
this tool is itself started from a venv under $HOME -- the usual case, since it
needs `datasets` -- bwrap's tmpfs hid the interpreter, the probe failed, and the
sandbox quietly fell back to a tier that does not hide $HOME at all. Nothing in
the output looked wrong when that happened, and the existing suites have been
running that way. The probe now uses the same interpreter and the same binds the
suite will actually get.

Two additions the agentic suite needs, neither of which changes the existing
suites:

* run_argv() runs an arbitrary command in a caller-owned directory that survives
  the call. run() is now a thin wrapper over it, so HumanEval and ClassEval take
  exactly the same path as before. A multi-step agent needs its edits to persist
  across many invocations, and a suite for a language other than Python needs to
  drive a linter and a test runner rather than a single source string.

* RLIMIT_AS is now per-sandbox, defaulting to what it was. It caps reserved
  address space rather than resident memory, which is the wrong instrument for a
  runtime that reserves a large region and commits little of it: node's
  TypeScript type-stripping goes through WebAssembly, reserves gigabytes per
  instance, and aborts at 4 GiB with an out-of-memory error that reads like a
  fault in the code under test.

HumanEval stays at 164/164 and ClassEval at 100/100.

Assisted-by: Claude Opus 5 <noreply@anthropic.com>

* llama-eval : add SACB, a multi-turn agentic coding suite

`--dataset agentic` is a different shape from the other code suites. Rather than
answering a single prompt, the model is given a small but real repository, a
narrow tool set and a bug report, drives its own multi-turn conversation, and is
graded by running a test suite it was never allowed to see.

The corpus is SACB (Simple Agent Coding Benchmark), 60 tasks over three
hand-written repositories in Python and TypeScript, hosted at ilintar/SACB.
Grading needs no container: a cached venv gives ruff and mypy for Python, and
node's own test runner plus tsc for TypeScript, so the whole dependency set is
four packages.

Three constraints are deliberate:

* No shell, and no way to run the tests. The only feedback channel is `lint`. A
  syntax or type error can be driven out mechanically; a logic error has to be
  reasoned about. A harness that lets an agent iterate against the test suite
  measures something closer to search than to understanding.
* Two edit tools, one line-addressed and one content-addressed. Which one a
  model reaches for, and whether it keeps line numbers straight after its own
  earlier edit, is itself a signal.
* The tests never touch the working tree. They are held outside it and copied
  into a throwaway copy only once the agent has stopped, so they can be neither
  read nor edited. Every tool path is resolved and confinement-checked against
  the repository root, so a symlink planted inside the tree cannot reach out of
  it.

A task counts as resolved when every test that was failing passes and nothing
that already passed broke. Both sets are derived by running the tests against
the defective tree and against the reference fix, never written by hand, so a
task whose defect no test exercises cannot enter the corpus. Many tasks carry
several independent defects, so the fraction of target tests passed is reported
alongside, separating "fixed two of the three faults" from "changed nothing".

An episode that arrives with no tool call and no `finish` is nudged rather than
ended. Models routinely narrate their analysis mid-task, and treating that as
completion ends the episode with no edits and scores a reasoning failure that
never happened; about one episode in four exits that way.

Nothing here is installed unless this suite is selected, and then only for the
language being run: agentic_eval is imported lazily and each toolchain is keyed
per language.

Assisted-by: Claude Opus 5 <noreply@anthropic.com>

* llama-eval : make the agentic hooks typed rather than discovered

`ty` flagged nine unresolved attributes, of which the seven in main() predate
the agentic suite: `EvalState.dataset` is Optional and was dereferenced without
narrowing.

* `loaded_dataset` turns the Optional into a value in one place, raising rather
  than handing back None, so callers do not each have to narrow it.
* `is_agentic`, `prepare()` and `run()` are declared on BaseDataset. They were
  reached through getattr with a default at three call sites, which no checker
  can see through; `run()` now raises a clear error naming the suite instead of
  failing as a missing attribute.
* The sandbox setup is one branch on `is_agentic` rather than two tests of
  `grader_type`, which also drops a duplicated condition.

No behaviour change: HumanEval stays at 164/164, and `ty check` on
examples/llama-eval is clean.

Assisted-by: Claude Opus 5 <noreply@anthropic.com>

* feat: API key

* fix reasoning and response length for agentic runs, add option to save entire transcripts

* llama-eval : fix ty unresolved-attribute on loaded_dataset.ae

Assisted-by: AI coding agent

* llama-eval : trim comments and remove non-ASCII from README

Assisted-by: AI coding agent
2026-10-16 11:52:18 +02:00
..
agentic_eval.py Add more benchmarks to llama-eval (#26174) 2026-10-16 11:52:18 +02:00
eval_sandbox.py Add more benchmarks to llama-eval (#26174) 2026-10-16 11:52:18 +02:00
llama-eval.py Add more benchmarks to llama-eval (#26174) 2026-10-16 11:52:18 +02:00
llama-server-simulator.py Add more benchmarks to llama-eval (#26174) 2026-10-16 11:52:18 +02:00
README.md Add more benchmarks to llama-eval (#26174) 2026-10-16 11:52:18 +02:00
test-simulator.sh examples : add llama-eval (#21152) 2026-05-12 15:07:00 +03:00

llama-eval

Simple evaluation tool for llama.cpp with support for multiple datasets.

For a full description, usage examples, and sample results, see:

Quick start

# Single server
python3 llama-eval.py \
  --server http://localhost:8033 \
  --model my-model \
  --dataset gsm8k --n_cases 100 \
  --grader-type regex --threads 32

# Multiple servers (comma-separated URLs and thread counts)
python3 llama-eval.py \
  --server http://server1:8033,http://server2:8033 \
  --server-name server1,server2 \
  --threads 16,16 \
  --dataset aime2025 --n_cases 240 \
  --grader-type regex

Authentication

A server started with --api-key (or any OpenAI-compatible endpoint behind a token) needs one here too. --api-key is sent as Authorization: Bearer on every eval request; it falls back to $LLAMA_API_KEY, which keeps the secret out of your shell history and off the process list.

# One key for every server
python3 llama-eval.py --server http://localhost:8033 --api-key sk-... ...

# From the environment
export LLAMA_API_KEY=sk-...
python3 llama-eval.py --server http://localhost:8033 ...

# One key per server, in --server order
python3 llama-eval.py \
  --server http://server1:8033,http://server2:8033 \
  --api-key sk-one,sk-two --threads 16,16 ...

The LLM grader authenticates too. It reuses the key of the server it runs on, so --grader-type llm without --grader-server needs nothing extra; a grader on a separate endpoint takes --grader-api-key.

Endpoints on an opencode host additionally get an x-opencode-session header, generated fresh for every task. An agentic episode sends the same id on all of its turns, so opencode sees one session per task instead of one per request; other servers never see the header.

Agentic suite

--dataset agentic is a different shape from the other code suites. It runs SACB (Simple Agent Coding Benchmark), hosted at ilintar/SACB -- 60 tasks over three hand-written repositories, in Python and TypeScript. Rather than answering one prompt, the model is given a small but real repository, a deliberately narrow tool set, and a bug report, and drives its own multi-turn conversation until it declares itself finished. It is then graded by running a test suite it was never allowed to see.

python3 llama-eval.py \
  --server http://localhost:8033 --model my-model \
  --dataset agentic --threads 1 --temperature 0

The tools

tool purpose
list_files enumerate the tree
read_file read, with 1-based line numbers and optional line-range narrowing
search regular-expression search across the repository
edit_lines replace an inclusive line range
edit_replace replace an exact snippet, which must be unique
write_file write a whole file
lint run the language's linter and type checker
finish declare the change complete

Three constraints are deliberate:

There is no shell and no way to run the tests. The only feedback channel is lint. A syntax or type error can be driven out mechanically; a logic error has to be reasoned about. A harness that lets an agent iterate against the test suite measures something closer to search than to understanding.

There are two edit tools, one line-addressed and one content-addressed. Which one a model reaches for, and whether it keeps line numbers straight after its own earlier edit, is itself a signal.

The tests never touch the working tree. They are held outside it and copied into a throwaway copy only after the agent has stopped, so they can be neither read nor edited. Every tool path is resolved and confinement-checked against the repository root, so a symlink planted inside the tree cannot reach outside it.

Scoring

A task is resolved when every test that was failing now passes and nothing that was already passing broke. Both sets are derived by running the tests twice when the corpus is built -- once against the defective tree and once against the reference fix -- never hand-written, so a task whose defect no test exercises is rejected rather than shipped.

Because many tasks contain several independent defects, the fraction of target tests passed is reported alongside, which separates "fixed two of the three faults" from "changed nothing". Process metrics are reported too: turns, tool calls, rejected calls, edits, and the peak conversation size.

Dependencies

Nothing here is installed unless you select this suite, and then only for the language you actually run. --agentic-lang python provisions ruff and mypy; --agentic-lang typescript provisions the TypeScript compiler and uses node's own test runner. Neither touches the other, and no other suite touches either.

Useful flags

flag meaning
--agentic-lang restrict to one language, and provision only its toolchain
--agentic-max-turns give up after this many assistant turns (default 50)
--agentic-max-tokens cap generation per turn (default: no cap); bounds a model that falls into a repetition loop, but also cuts reasoning off mid-thought. Truncated turns are counted as truncated_turns
--agentic-context-limit abandon an episode once its context exceeds this many tokens
--agentic-transcripts DIR save each episode's full message history to DIR/<task_id>.json, plus a DIR/<task_id>.html showing every reasoning block, reply, tool call and tool result; the main report links to it

Code suites (HumanEval, ClassEval)

--dataset humaneval runs the 164 OpenAI HumanEval problems; --dataset classeval runs the 100 ClassEval class-generation tasks. Unlike the other suites these are not graded by matching text: the model's reply is parsed for a ```python block, that code is combined with the task's own unit tests, and the result is executed.

ClassEval asks for a whole class rather than one function, so it is scored the way upstream does - class level (every one of the task's tests passes, which is what feeds pass@k) and method level (what fraction of all 2196 test methods passed). The second number separates "generated nothing usable" from "got most of the class right", and both are printed at the end of a run.

Give it more room to generate than HumanEval - a class with ten methods does not fit in a short budget:

python3 llama-eval.py \
  --server http://localhost:8033 --model my-model \
  --dataset classeval --threads 8 --n_predict 4096 --temperature 0
# pass@1, greedy
python3 llama-eval.py \
  --server http://localhost:8033 --model my-model \
  --dataset humaneval --threads 8 --n_predict 1024 --temperature 0

# pass@10 -- each extra multiple of 164 is another sample per problem
python3 llama-eval.py \
  --server http://localhost:8033 --model my-model \
  --dataset humaneval --n_cases 1640 --temperature 0.8 --top-p 0.95

--grader-type exec is the default for code suites, so neither it nor --dataset-source normally needs to be passed.

Execution sandbox - no Docker required

Generated code is untrusted, but these suites need far less than a container to run safely. The package set is derived from the dataset's own imports at startup, not hardcoded and not taken from the upstream requirements.txt - which for ClassEval lists torch, openai and transformers that its tests never touch. So each suite installs exactly what it needs:

suite third-party packages
HumanEval none - every problem imports only stdlib (typing, math, random, copy, string, collections, hashlib, re)
ClassEval 13: beautifulsoup4, gensim, lxml, netifaces, nltk, numpy, openpyxl, pandas, pillow, pypdf2, python-docx, reportlab, scipy

Two of ClassEval's are declared rather than derived, because no import statement mentions them: lxml is selected by string (BeautifulSoup(html, 'lxml'), ClassEval_44 - without it that task silently drops from 23/23 to 7/23), and scipy is a hard gensim requirement. Note also that PyPDF2 must not be modernised to pypdf: ClassEval_69's test imports PdfFileReader, a name pypdf removed.

The venv is cached under ~/.cache/llama-eval/ (override with LLAMA_EVAL_CACHE) and rebuilt only when the package set, interpreter version or provisioning steps change; force one with --rebuild-sandbox. uv is used when available, which makes even the ClassEval venv build in seconds.

Interpreter selection. A suite can prefer a different Python than the one running the tool, and declares it: ClassEval asks for 3.13, because gensim publishes no wheel past cp313 and its generated C no longer compiles on 3.14 (it still uses PyLongObject.ob_digit). That interpreter is found automatically, including via uv python; --sandbox-python overrides it.

3.14 is fully supported anyway. ClassEval uses exactly two gensim APIs (utils.decode_htmlentities, matutils.unitvec), both pure Python upstream, so on 3.14+ the suite drops gensim from the install set and provisions a small shim instead. Both paths score 100/100.

Offline provisioning. A suite that would otherwise fetch data at test time declares a provisioning step that runs once at venv build time, while the network is still up. ClassEval uses this to pre-seed exactly three nltk corpora (punkt_tab, averaged_perceptron_tagger_eng, wordnet, ~33 MB), so the tests themselves run with no network at all. Those are the current names - the dataset asks for punkt and averaged_perceptron_tagger, which nltk has since renamed, and seeding the legacy names too is not harmless: with a network available nltk considers them satisfied and re-fetches the modern ones anyway. The corpora are written inside the venv, not ~/nltk_data, because $HOME is a tmpfs under the strongest isolation tier.

Each candidate program runs in a throwaway directory as its own process group, under the strongest isolation the host offers:

tier requires isolates
bwrap bubblewrap installed read-only root, tmpfs /tmp and $HOME, no network
unshare unshare --map-current-user no network
unshare-root any unshare -r no network, but runs as uid 0 - see below
rlimit always available cpu/memory/file-size/process caps only

Candidate code must not run as root, which is why unshare -r is a last-resort tier that prints a warning. Root bypasses file permission bits, so a test asserting that writing to a chmod 0444 file fails instead sees it succeed - ClassEval_50 scores 14/16 under uid 0 and 16/16 under uid 1000, with nothing in the output to suggest the sandbox caused it. Install bubblewrap.

All tiers additionally cap CPU time, address space (4 GiB), file size and core dumps, and kill the whole process group on timeout (--exec-timeout, default 15 s - raise it for ClassEval, whose tests do real work). Writes to the throwaway working directory are allowed, which is all the file-handling tasks in ClassEval need.

Note that the weakest tier does not contain filesystem access. If you are scoring output from a model you do not trust, check the tier printed at startup.

Reproducibility

Several tasks draw their test inputs from the global, unseeded RNG, so a verdict is not reproducible run to run - which adds noise exactly when you are comparing two models or two quantisations. --exec-seed (default 0) seeds the RNG before each test; pass --exec-seed -1 for the stock upstream behaviour. This does not change what the model is asked to compute.

It matters more than it sounds. HumanEval/38, /50 and /53 are affected, and ClassEval_58 (duplicate mine placement) fails about 30% of the time on its own reference solution - measured here at 21/30 passes unseeded versus 30/30 seeded.

Dataset fidelity

The canonical dataset runs unmodified on current Python - all 164 reference solutions pass on CPython 3.14 with no patches, so no forked or re-hosted copy is needed. --dataset-source accepts a local .jsonl or a HuggingFace repo id if you do want to point at a modified copy.

HumanEval does contain a handful of long-standing defects where the prompt contradicts the graded test (notably HumanEval/47, /116 and /148). Those penalise a model that reads the docstring carefully, but correcting them makes the benchmark marginally easier and the resulting scores no longer comparable with published HumanEval numbers, so nothing is patched by default.

ClassEval is different: the upstream data has defects that cap the achievable score below 100% for reasons unrelated to the model, so --dataset classeval defaults to a patched fork, ilintar/ClassEval. Six edits over five tasks lift the reference solutions from 96/100 classes and 2182/2196 test methods to 100/100 and 2196/2196. Three repair tests nothing could pass:

  • ClassEval_17 was a time bomb - a hard-coded 2024 date checked against datetime.now(), unpassable since January 2024.
  • ClassEval_48 asserted get_hostname('0.0.0.0') == 'LAPTOP-2CS86KUM', the dataset author's own machine name.
  • ClassEval_31 compared floats with assertEqual, failing on a 4-ULP numpy difference.

The other three fix only the reference solutions (NumPy 2.0 removals in ClassEval_51, a 30%-flaky mine generator in ClassEval_58) and cannot affect a model's score. No skeleton was touched, so what the model is asked to write is byte-identical to upstream. Pass --dataset-source FudanSELab/ClassEval for the unpatched original.