Tiny experiment run ledger for CLI programs under a time budget. Capture complete run snapshots, sweep configs with an anytime cutoff, and always keep a submittable best-known incumbent β without a heavyweight experiment platform.
measure -> keep -> compare -> hold the best (in a form you can always submit)
- Status: v0.1.0 (pre-release) Β· MIT Β· pure standard library, no runtime dependencies
- Verified: 35 tests passing on Python 3.12 / Linux (WSL2), 2026-06-30 β see Verification
- Known limitation: not yet verified on Windows (see Known limitations)
Ad hoc experiment logs disappear, and when a job is killed you lose the best
result. runledger makes every run inspectable β stdout, stderr, env, git
state, input hash, resource usage β and always keeps the current best in
state/incumbent.json. Even if a job is preempted mid-sweep, you still have a
valid result to submit.
| vs | what runledger adds |
|---|---|
| Guild AI / DVC exp | An anytime cutoff under a deadline + a submittable incumbent.json β not "run every trial, pick the best afterwards". |
| hyperfine | A config sweep + full snapshots + an incumbent, not just timing statistics. |
| Optuna / GPTune / OpenTuner | runledger is not an optimizer. LHS/grid/manual configs are enough; a smarter search engine is an upstream that simply emits configs.tsv. |
The wedge: deadline-bound CLI experiments that may be killed, where you must always have the current best in a submittable form. Think HPC job runners, competitive/optimization solver authors, and benchmark sweepers β not ML experiment tracking.
- Anytime β before each config, if the remaining time cannot finish it, the sweep stops cleanly and keeps everything already measured.
- Submittable incumbent β
state/incumbent.jsonis updated live and only ever moves to a strictly better, correct result. - A non-zero exit is never trusted as a measurement β crashes and timeouts can never masquerade as a good result.
All three are demonstrated in Verification.
cd runledger
pip install -e ".[dev]"Requires Python 3.11+. No third-party runtime dependencies (pytest is dev-only).
# 1. Snapshot a single run
runledger run --name baseline --timeout 30 -- python3 examples/hello/solver.py --alpha 1.5 --budget 0.1
# 2. Generate configs from a search space (Latin hypercube, reproducible with --seed)
runledger gen-configs examples/hello/solver.space.tsv --n 8 --seed 1 -o configs.tsv
# 3. Sweep with an anytime cutoff; the incumbent updates live
runledger sweep configs.tsv --budget 1 --elapse 30 --objective max-score -- python3 examples/hello/solver.py
# 4. (Re)compute the incumbent from a finished sweep
runledger incumbent sweeps/latest --objective max-score
# 5. Summarize a run or a sweep
runledger summary runs/latest
runledger summary sweeps/latestrunledger run -- <cmd> # execute + snapshot one command
runledger gen-configs space.tsv # space.tsv -> configs.tsv (lhs|grid)
runledger sweep configs.tsv -- <cmd> # anytime sweep: results.csv + live incumbent
runledger incumbent <sweep-dir> # best correct config for an objective
runledger summary <run|sweep dir> # human-readable summaryObjectives: max-score, min-elapsed, score-per-sec.
runs/<timestamp>-<name>/ meta.json argv.txt stdout.txt stderr.txt env.txt status.txt resource.json input.sha256
sweeps/<round>/ configs.tsv results.csv errors.log runs/<id>_rep<NNN>/
state/incumbent.json the always-submittable best-known result
latest is a symlink (falling back to latest.txt where symlinks are
unavailable). See docs/snapshot-format.md and
docs/sweep-format.md for the full schemas.
Your program reports its result however it likes; runledger reads it via a measure spec. A non-zero exit is never trusted, with any kind.
| kind | how |
|---|---|
tune-line (default) |
last #TUNE elapsed=.. score=.. correct=.. line on stderr |
regex |
patterns over stdout/stderr (configured in a [measure] table) |
json:PATH |
a JSON file {"elapsed":..,"score":..,"correct":..} |
file:PATH |
a plain file whose first line is the score |
none |
exit/wall/outcome only |
#TUNE is the zero-config default β not a requirement. See
docs/measure-spec.md.
Captured on Python 3.12.3 / Linux 6.18 (WSL2) / 2026-06-30.
tests/test_cli.py 3 (clean error handling: bad --measure / --objective)
tests/test_gen_configs.py 6 (space parsing, LHS/grid, pow2 rounding, dedup)
tests/test_incumbent.py 6 (objectives, correct-only, strict-improvement, no regression)
tests/test_measure.py 9 (tune-line/regex/json/file/none, non-zero not trusted)
tests/test_run.py 7 (success/failure/timeout/missing-bin, input hash, env allowlist)
tests/test_sweep.py 4 (results append, continue past failure, anytime, dup-id)
---
35 passed in 0.74s
| Behavior | Command | Observed |
|---|---|---|
| Snapshot + measure | runledger run -- β¦ solver.py |
completed exit=0; tune-line measure extracted; 7 files written to runs/latest/ |
| Reproducible sweep | gen-configs --seed 1 then sweep |
incumbent converged to id=003 scoreβ1.808; 4 live updates over 5 configs |
| Anytime cutoff | sweep --budget 100 --elapse 0.001 |
configs run: 0/1 (stopped early); errors.log: anytime cutoff before id 000 |
| Non-zero not trusted | run prints #TUNE β¦ then exit 3 |
outcome=failed; measure: elapsed=None score=None correct=None |
| Clean error handling | --measure bogus / --objective bogus |
error: unknown β¦ + exit=2, no traceback, nothing executed |
Compiled C++ via --bindir |
make β gen-configs --bin solver β sweep --bindir build |
built with g++ 13.3.0; 8/8 configs run; argv.txt resolves to build/solver; incumbent id=000 score=1.95 |
Representative output:
$ runledger sweep configs.tsv --budget 1 --elapse 30 --objective max-score -- python3 solver.py
sweep: sweeps/round-001
configs run: 5/5 incumbent updates: 4
$ runledger sweep one.tsv --budget 100 --elapse 0.001 -- python3 solver.py
sweep: sweeps/round-002
configs run: 0/1 incumbent updates: 0 (stopped early)
# errors.log: anytime cutoff before id 000: spent=0.0s est=100.0s elapse=0.0s
$ runledger run -- python3 -c "import sys; print('#TUNE score=999 correct=1', file=sys.stderr); sys.exit(3)"
failed exit=3 wall=0.013s
measure[tune-line]: elapsed=None score=None correct=None # #TUNE ignored on non-zero exitThe compiled examples/cpp-solver was also built and swept end-to-end (g++ 13.3.0):
$ make && runledger gen-configs solver.space.tsv --n 8 --seed 1 --bin solver -o configs.tsv
$ runledger sweep configs.tsv --bindir build --budget 1 --elapse 30 --objective max-score
sweep: sweeps/round-001
configs run: 8/8 incumbent updates: 1
$ cat sweeps/latest/runs/000_rep001/argv.txt # binary resolved from the bin column
build/solver
--alpha
1.949822
--budget
0.075639
$ runledger incumbent sweeps/latest --objective max-score
incumbent updated: state/incumbent.json
id=000 objective=max-score score=1.949822 elapsed=0.075639CI runs the Python test suite on Python 3.11 / 3.12 / 3.13.
runledger records env vars (allowlist by default), the command line,
stdout/stderr, an input hash, and git state. The full environment is recorded
only with --capture-env=all; input contents are copied only with
--copy-input, and secret-like files are skipped. See
docs/security.md.
- docs/snapshot-format.md β run directory + meta.json
- docs/sweep-format.md β configs.tsv, results.csv, incumbent.json, anytime
- docs/measure-spec.md β measurement kinds
- docs/hpc.md β launchers, ranks/omp, scheduler notes
- docs/security.md β env/secret handling
Examples: examples/hello (pure Python),
examples/cpp-solver (compiled, --bindir),
examples/hpc-like (--launcher, ranks/omp).
review-artifact runs a read-only AI triage over the snapshots runledger produces:
runledger run -- ./solver < input.txt
review-artifact logs runs/latest # read-only AI triage of the snapshot- Windows is not yet verified. The symlink β
latest.txtfallback and the emptyresource.jsonpath are implemented but untested on Windows; treat Windows as best-effort for now. resource.jsonis best-effort: agetrusagedelta on Unix,{}elsewhere.maxrssis a high-water mark, not a precise per-run figure.gen-configs --method griddiscretizes float dimensions coarsely (lo/mid/hi); LHS is the primary method.
Scheduler submit adapters (PJM/SLURM/PBS), web UI, SQLite index, cloud sync, optimizer / Bayesian search, GitHub bot.
MIT β see LICENSE.