A "benchmark" in LoopBench is two things working together:
- A correctness gate — your tests pass/fail. Any failure forces the score to
0.0, so a candidate that breaks behavior is always rejected. - A metric to optimize — a number (latency, memory, accuracy, …) that LoopBench tries to improve, generation after generation.
These combine into a single combined_score that drives evolution:
combined_score = correctness × metric_score
There are four ways to define this, from fastest to most flexible (A–D below). Pick one — then see Optimizing an external repo to package it as a config job for someone else's repo.
Best when you just want to point LoopBench at a file and go. The benchmark is
your repo's own pytest suite. You need two things in a test_*.py file:
- Assertions that verify correctness.
- One test that measures the hot path and prints a marker line:
LOOPBENCH_SPEED_MS=<number>.
# 0. Docker must be running
docker info
# 1. Your project layout — the test lives next to the file being optimized
# my_repo/
# ├── slow_module.py <- file to optimize
# └── test_slow_module.py <- correctness + speed markerWrite the test so it emits the speed marker:
# test_slow_module.py
import importlib.util, os, time, types
import pytest
_PATH = os.environ["LOOPBENCH_PROGRAM_PATH"] # set by LoopBench
def _load():
spec = importlib.util.spec_from_file_location("evolved", _PATH)
m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
return m
@pytest.fixture(scope="session")
def prog(): return _load()
# ── correctness gate ──
def test_correct(prog):
assert prog.solve(10) == 55
# ── the metric being optimized ──
def test_speed(prog):
start = time.perf_counter()
prog.solve(50_000)
elapsed_ms = (time.perf_counter() - start) * 1000
print(f"\nLOOPBENCH_SPEED_MS={elapsed_ms:.4f}") # <- LoopBench reads this
assert elapsed_ms < 5000 # hard safety limitRun the optimization:
loopbench run \
--target . \
--target-file slow_module.py \
--metric latency \
-i 5
# Optional: override the auto-detected test command
loopbench run --target . --target-file slow_module.py \
--test-command "pytest test_slow_module.py -s -q" -i 5The scoring formula lives in sandbox/entrypoint.sh:
correctness = 1.0 if all_passed else 0.0
speed_score = exp(-speed_ms / 150.0) # tune the 150.0 for your latency scale
combined_score = correctness * speed_scoreIf your latencies are much larger (e.g. thousands of ms), raise the 150.0
decay constant so scores spread meaningfully across your range.
Template you can copy:
examples/prime_counter_optimizer/test_prime_counter.py
Best when "better" isn't a single timing — e.g. you want to weight accuracy vs.
speed vs. memory. You don't need a separate evaluator.py — your test file is
the evaluator. Compute whatever you want inside the test and expose it two ways:
- Do the weighting yourself and print one number as
LOOPBENCH_SPEED_MS(Option A) — e.g.score = time_ms / accuracy, so lower time and higher accuracy both help. - Or print several named numbers and choose which to optimize with a regex
metric (Option C) plus
--metric <name>.
Correctness stays the hard gate: any failing test scores 0.0.
Advanced (rarely needed). A fully custom
evaluate(program_path) -> EvaluationResultscorer is also supported through the separate OpenEvolveoptimizerengine (optimizer run --config ...).This is not "the smart part" — the intelligence (the LLM that writes the improvements + the evaluator-driven loop that keeps the best and learns from failures) is identical in both. The only difference is the search strategy:
loopbench runuses greedy/beam selection, while theoptimizerengine adds MAP-Elites / island populations for broader exploration on very hard, open-ended problems. For "make this faster and keep tests green," the default flow is just as capable (and cheaper).
Best when your tests already print performance numbers in their own format and
you don't want to adopt the LOOPBENCH_SPEED_MS convention. LoopBench extracts
the metric with a regex via its MetricParser.
Add a metrics section to your config:
metrics:
patterns:
execution_time: 'Mean:\s*([\d.]+)\s*seconds' # captures the number in group 1
throughput: 'ops/sec:\s*([\d.]+)'
success_threshold: 0.10 # min improvement to count as a winSet which captured metric to optimize via metric.name in the config (e.g.
throughput), then run:
loopbench run --config my_project/loopbench.yamlLoopBench runs your tests, greps each pattern out of stdout/stderr, and
optimizes the metric you named (falling back to combined_score).
Best for scripts that read stdin and write stdout at module top level —
competitive-programming solutions, CLI tools, filters. The default harness
imports the target, which crashes such scripts (they call input() on
import). Run mode instead executes the target as a subprocess, feeds it
stdin, and compares stdout against expected output.
Provide a JSON file of I/O cases:
[
{"name": "sample", "input": "2\nab\nbabba\n", "output": "NO\nYES"},
{"name": "big_no", "input": "1\nabcabc...\n", "output": "NO"}
]Then point LoopBench at the script with --io-tests:
loopbench run \
--target . \
--target-file path/to/solution.py \
--io-tests path/to/io_tests.json \
-i 6LoopBench auto-generates a pytest harness that runs python solution.py per
case, checks the output, and times the heaviest case for the speed score. You
can also drop the cases in a conventional file next to the target
(<stem>.io.json or io_tests.json) and omit --io-tests — it's auto-detected.
- Correctness = every case's stdout matches (compared line-by-line, trailing whitespace ignored).
- The metric = wall-clock time of the largest input.
Template you can copy:
examples/stdin_palindrome/(solution.py+io_tests.json). Verified end-to-end: a naive O(n³) stdin solver was evolved to O(n), all cases green, on a script that can't even be imported.
LoopBench is cost-bounded: the loop stops early when a token or dollar budget is
reached (in addition to --iterations). This applies to hero mode
(--target ...).
# Stop after 50k total LLM tokens (works with any provider — tokens are
# reported by the API and always enforceable)
loopbench run --target . --target-file src/hot.py --metric latency --max-tokens 50000
# Stop after an estimated $0.25 spend (requires pricing, see below)
loopbench run --target . --target-file src/hot.py --metric latency --max-cost 0.25
# Stop after 300 seconds of wall-clock time
loopbench run --target . --target-file src/hot.py --metric latency --max-runtime 300Or declare them in loopbench.yaml under constraints (CLI flags override):
constraints:
max_iterations: 20
max_tokens_total: 50000 # hard token budget
max_token_cost_usd: 0.25 # dollar budget (needs pricing below)
max_runtime_seconds: 300 # wall-clock deadline for the whole run
usd_per_1k_prompt: 0.00059 # your provider's input price per 1k tokens
usd_per_1k_completion: 0.00079 # output price per 1k tokensThe other two constraints from the spec are always on and need no config: the
sandbox runs with --network=none (no external network) and mounts your
code read-only (no unsafe file writes).
loopbench run chooses which prior candidate each new generation builds on.
By default this is auto: it starts greedy (cheapest) and escalates only
when the run stops improving — no extra LLM calls, fully deterministic.
stall (generations since last improvement) |
Behavior |
|---|---|
< restart_patience (default 2) |
greedy — build on the best so far |
restart_patience … diversify_patience (2–4) |
restart — revert to the original baseline for a fresh path |
≥ diversify_patience (default 4) |
diversify — rotate through the top-K candidates |
You rarely need to touch this. To pin a strategy, use --strategy or a
search: block in loopbench.yaml (CLI overrides config):
loopbench run --target . --target-file src/hot.py --strategy random_restartsearch:
strategy: auto # auto (default) | greedy | beam | random_restart
restart_patience: 2 # auto: greedy → restart threshold
diversify_patience: 4 # auto: restart → diversify threshold
beam_width: 3 # auto (diversify tier) and beam
restart_interval: 20 # random_restart onlyEscalation only steers exploration — the run always reports the highest-scoring
candidate, so auto never does worse than plain greedy. For the design and
per-strategy details, see
Architecture → Search Strategy.
To optimize a file in someone else's repo, you write a small job folder in your
own workspace — the same structure as the examples — and point its
loopbench.yaml at the external repo. Scaffold it in one command so you only
edit configuration:
loopbench init --job my_job # creates my_job/loopbench.yaml + my_job/test_target.py
``` You never edit files inside the target
repo; LoopBench clones it, injects your evaluator into the sandbox, and
optimizes the named file.
my_job/ ├── loopbench.yaml # points at the external repo + file to optimize └── test_target.py # the evaluator/test (correctness gate + LOOPBENCH_SPEED_MS)
`loopbench.yaml`:
```yaml
target:
repo: https://github.com/OmkarPathak/Python-Programs # cloned automatically
file: MachineLearning/gradient_descent.py # the file to optimize
evaluator: test_target.py # local (in this job dir)
sandbox:
command: "pytest test_target.py -v -s -q"
pip: ["numpy", "matplotlib"] # installed in the sandbox
metric:
name: "combined_score"
threshold: 0.90
constraints:
max_iterations: 20
max_tokens_total: 200000
Then run:
loopbench run --config my_job/loopbench.yamltest_target.py loads each candidate from LOOPBENCH_PROGRAM_PATH, asserts
correctness, and prints LOOPBENCH_SPEED_MS — exactly like Option A/B above. The
target repo stays untouched; everything you author lives in my_job/.
A local file you own keeps using
target.program(evaluator-first controller);target.repo+target.fileis the external-repo path (clone + sandbox).
Real code imports packages that aren't in the base sandbox. LoopBench detects
them and installs them into a cached, per-dependency-set image layered on the
base — the install is the only networked step; the scored run stays
--network=none. Detection priority (authoritative first):
--pip "numpy scipy"(explicit; also settable assandbox.pipin config)- a
requirements.txtat the repo root pyproject.tomldependencies (PEP 621 or Poetry)- imports scanned across every
.pyfile in the repo — a best-effort fallback (import names mapped to PyPI names; stdlib/local modules filtered)
# Auto-detected from the repo's requirements.txt / pyproject.toml / imports:
loopbench run --target . --target-file src/model.py --metric latency
# Or pin them explicitly (fast, deterministic — recommended for real projects):
loopbench run --target . --target-file src/model.py --pip "numpy scipy"The run prints which source the dependencies came from, so you can gauge confidence. The first run with a new dependency set builds the image (a minute or two); later runs reuse it.
Reliability note. Import-scanning is a convenience, not a guarantee — it can't resolve every import→PyPI name (e.g.
cv2→opencv-python), misses dynamic/optional imports, and infers no versions. For large or real projects, prefer a declared source: arequirements.txt/pyproject.tomlin the repo, or an explicit--pip/sandbox.pip. LoopBench always trusts those over the import scan.
By default the sandbox runs pytest. To use a different command — a benchmark
harness, a type checker, or a plain script — pass --test-command (hero mode)
or set sandbox.command (config mode):
loopbench run --target . --target-file src/hot.py \
--test-command "python benchmark.py"For a pytest command, correctness comes from the pass/fail report. For any
other command, correctness is the command's exit code (0 = pass). In both
cases, print a LOOPBENCH_SPEED_MS=<number> line to feed the speed score.
Token counts come from the provider's usage field (OpenAI, Groq, and Google
AI Studio all report it). The dollar estimate needs the two pricing fields — if
they're 0, the USD budget is inactive but the token budget still works. Every
generation's token/cost delta is written to the run's audit log, and the run
summary reports total tokens, estimated cost, and whether the budget stopped it.
Set usd_per_1k_prompt / usd_per_1k_completion to your provider's current
per-1,000-token rates (pricing changes often — always check the provider page):
- Groq — groq.com/pricing
- OpenAI — openai.com/api/pricing
- Google Gemini — ai.google.dev/pricing
Example: if a provider charges $0.59 per million input tokens, that is
0.59 / 1000 = 0.00059 per 1k, so usd_per_1k_prompt: 0.00059.
A ready-to-use budget block ships in
examples/fibonacci_optimizer/loopbench.yaml.
Run the fibonacci demo with a small token cap:
loopbench run \
--target . \
--target-file examples/fibonacci_optimizer/initial_program.py \
--metric latency \
-i 5 \
--max-tokens 1Because the baseline uses no LLM tokens, generation 1 runs, then the loop stops at the budget gate before generation 2. The summary reports what was spent:
Total Generations: 1
...
Tokens used : 896 (1 API calls)
------------------------------------------------------------
Swap --max-tokens 1 for a realistic value (e.g. --max-tokens 50000) or use
--max-cost 0.25 once pricing is set in the config.
A single timing is noisy. The same code can measure 5% faster or slower run to run because of CPU scheduling, cache state, and background load. Left unchecked, that noise lets LoopBench "accept" a candidate that isn't actually faster — it just got a lucky sample. The statistical speed gate measures each candidate several times and only accepts a win that clears the noise.
It is off by default in the sense that the defaults reproduce the original
single-shot behavior: repeats=1 measures once and accepts any median
improvement, exactly as before. Turn it on by asking for more repeats.
Set sandbox.repeats in loopbench.yaml to the number of times the speed
workload is measured per candidate:
sandbox:
repeats: 5 # measure each candidate 5 times (default: 1)repeats: 1(default) — single-shot measurement. Identical to the original behavior; nothing else in this section changes the outcome.repeats >= 3— the first run is discarded as a warm-up (it pays cold-cache and JIT/import costs that don't reflect steady state), and the remaining runs are kept.repeats: 5is the recommended value for noise-robust measurement: after dropping the warm-up you keep four samples, enough for a stable median and a meaningful spread without paying for many extra runs.
Repeated measurement only re-runs the sandboxed workload — it costs no extra LLM tokens.
With repeats configured, a candidate becomes the new best only if both conditions hold:
- (a) Real effect: the relative median improvement clears
metric.min_effect—(median_base − median_cand) / median_base >= min_effect. - (b) Above the noise: the candidate's median, padded by the larger of the
two spreads, still beats the baseline median —
median_cand + max(stddev_cand, stddev_base) < median_base.
metric:
min_effect: 0.03 # require a 3% relative median speedup (default: 0.03)In plain language: condition (a) rejects wins too small to matter, and condition
(b) rejects wins that are within measurement noise. If the improvement is smaller
than how much the numbers jitter between runs, we don't trust it, so we don't
accept it. With repeats=1 both spreads are 0, so condition (b) collapses to
"is the candidate's median lower?" and the gate behaves like the original
single-shot comparison.
Each scored candidate now records the full speed distribution, not just one number:
| Field | Meaning |
|---|---|
speed_ms |
The median of the kept runs (was a single timing before). Kept for backward compatibility. |
speed_ms_median |
Median of the kept per-run timings (ms). |
speed_ms_mean |
Mean of the kept timings (ms). |
speed_ms_stddev |
Sample standard deviation of the kept timings (ms); 0.0 when only one run is kept. |
speed_ms_samples |
The kept per-run timings (warm-up already dropped). |
runs |
How many runs were kept. |
Because speed_ms is now the median, older consumers that read speed_ms
continue to work unchanged.
Even with repeated measurement, the candidate that wins the loop was chosen partly because it measured well during the loop. To guard against a winner that got a favorable sample, LoopBench re-runs the winning candidate after the loop and checks that the speed gain still holds. This is on by default.
- Revalidation re-applies the winner's patch and re-measures it
Mtimes in the sandbox (defaultM = 7). It builds no prompts and makes no LLM calls, so it adds no token cost. - The run is marked
successfulonly if the re-measured distribution still clears the same speed gate (conditions (a) and (b) above) against the baseline. If the gain no longer holds, the status is downgraded torevalidation_failed— the code and patch are still reported, but the claimed speedup didn't survive re-measurement.
Control it from the CLI:
# Revalidation is ON by default — nothing to do to enable it.
loopbench run --target . --target-file src/hot.py --metric latency
# Re-measure the winner 15× instead of the default 7
loopbench run --target . --target-file src/hot.py --metric latency \
--revalidate-runs 15
# Skip revalidation entirely
loopbench run --target . --target-file src/hot.py --metric latency \
--no-revalidate--no-revalidate turns the final check off; --revalidate-runs N sets how many
times the winner is re-measured (default 7, ignored when --no-revalidate is
set).
Everything above assumes the default sandbox — a Python image running pytest.
But the correctness/speed contract is language-agnostic: a candidate passes if a
command exits 0, and its speed is whatever it prints as LOOPBENCH_SPEED_MS.
Two config keys let you point that contract at a Node, Go, Rust, or any other
toolchain by swapping the base image.
sandbox.image (default null → the default LoopBench Python sandbox) sets the
base Docker image the candidate runs and is scored in. Point it at any image
that has your toolchain:
sandbox:
image: node:20-alpine # or golang:1.22-alpine, rust:1-slim, …Leaving it unset (or blank) keeps today's behavior exactly — the Python sandbox, byte-for-byte. When you do set it, LoopBench builds a small derived image on top of your base and layers in its own entrypoint + scorer automatically (see How scoring works without Python below), so you only supply the base image, any build steps, and a command.
sandbox.setup (default [] → none) is an ordered list of shell commands run
once at image-build time, on top of the base, before any candidate runs.
Use it to install dependencies or compile a project:
sandbox:
image: node:20-alpine
setup:
- npm ci
- npm run buildA single string is also accepted (setup: "npm ci") and treated as one step.
Order is preserved and steps are not de-duplicated — they are literal RUN
lines in the derived image, so npm ci then npm run build run in that order.
The derived image is cached by a hash of the base image, the setup steps, and
any pip packages, so repeated runs reuse it instead of rebuilding. As with the
Python dependency layer, the build is the only step with network access —
the scored run still executes with --network=none.
pip is never forced onto a custom base.
sandbox.pippackages are layered only when you actually declare them, so an image without Python (or without pip) works fine.
For a pytest sandbox.command (or --test-command), scoring is unchanged —
the structured pass/fail report drives correctness. For any other command,
LoopBench uses a toolchain-agnostic scorer written in shell + awk with no
python3 dependency, so it runs on images that have no Python at all:
- Correctness = the command's exit code (
0= pass, nonzero = fail). - Speed = the median of the
LOOPBENCH_SPEED_MSmarkers the command prints (across repeats, warm-up dropped whenrepeats >= 3).
The formula, rounding, and score.json fields are identical to the Python
scorer for the same inputs — the only difference is the implementation language.
When you use a custom image, LoopBench copies its entrypoint and this generic
scorer into the derived image and sets the entrypoint for you; nothing about the
scoring contract changes.
Limitation — correctness is exit-code based. Outside the pytest path there is no structured test report, so a non-pytest command must exit nonzero when it fails and
0only when it truly passes. If your command always exits0(e.g. a runner that prints "FAILED" but returns success), correctness will read as passing. Make the command itself assert and exit nonzero on failure — or run your tests through pytest to get report-based correctness.
Optimize a Node solution. The target prints LOOPBENCH_SPEED_MS=<ms> for the
hot path and exits 0 when the result is correct (nonzero otherwise):
// solution.js
function solve(n) { /* the hot path being optimized */ }
const t0 = process.hrtime.bigint();
const result = solve(50000);
const elapsedMs = Number(process.hrtime.bigint() - t0) / 1e6;
console.log(`LOOPBENCH_SPEED_MS=${elapsedMs}`); // <- LoopBench reads this
process.exit(result === 1666416667 ? 0 : 1); // exit 0 = pass, nonzero = fail# loopbench.yaml
target:
file: solution.js
sandbox:
image: node:20-alpine
setup: ["npm ci"] # omit if the target has no dependencies
command: "node solution.js" # non-pytest → exit-code correctness + speed marker
metric:
name: "combined_score"loopbench run --config loopbench.yamlLoopBench builds node:20-alpine + npm ci once, then runs node solution.js
per candidate with --network=none, scoring correctness from the exit code and
speed from the printed marker — the same combined_score = correctness × speed
you get in Python mode. See
Custom sandbox commands for
the command key and Third-party dependencies
for how the caching layer relates to sandbox.pip.
Every run writes artifacts to loopbench_output/ (hero mode) or your
configured output dir:
# The winning diff
cat loopbench_output/best.patch
# Before/after metrics and patch status
cat loopbench_output/report/validation_report.md
# Proof the winning candidate kept all tests passing
cat loopbench_output/test_log.txtThe run also writes docs/data.json; view the trajectory on the dashboard:
python -m http.server 8080 --directory docs # then open http://localhost:8080| You want… | Use | Where you set the benchmark |
|---|---|---|
| Point-and-go on a file/repo | A | test_*.py (LOOPBENCH_SPEED_MS) + sandbox/entrypoint.sh formula |
| Combine multiple signals (accuracy + speed + memory) | B | compute one score in your test_*.py, or a named metric via C |
| Reuse existing perf output | C | metrics.patterns regex in the config |
| Optimize a stdin/stdout script | D | --io-tests JSON of input/output cases |
All modes are cost-bounded — see Constraints & cost budget to cap tokens or dollars.
See the Quick Start for the end-to-end 5-minute walkthrough.