A zero-dependency evaluation gate that fails CI for real LLM regressions, not statistical noise.
EvalGuard compares two lm-evaluation-harness
results files and answers one question: did this change make the model worse, or did the
numbers just wobble?
The demo compares a candidate checkpoint against a baseline. MMLU moved, but stayed inside its noise band; GSM8K genuinely regressed:
+---------------------------------+----------+-----------+-------------+--------------+
| Task | Baseline | Candidate | Noise Band | Status |
+---------------------------------+----------+-----------+-------------+--------------+
| gsm8k/exact_match[strict-match] | 0.7814 | 0.6123 | +/- 0.02433 | REGRESSION |
| mmlu/acc | 0.6518 | 0.619 | +/- 0.05643 | PASS (noise) |
+---------------------------------+----------+-----------+-------------+--------------+
$ bash demo/run_demo.shOne command, no API key, no evaluation jobs. It exits successfully because EvalGuard correctly rejected the GSM8K regression, and prints an actionable diagnosis:
gsm8k/exact_match[strict-match] dropped from 0.7814 to 0.6123 (21.64%), outside the
noise band +/- 0.02433 and the 5.00% limit. Check the model checkpoint, tokenizer/chat
template, decoding parameters, and evaluation sample counts; rerun on the same harness
version before merging.
MMLU's 0.0328-point move exceeds the relative threshold, but its reported standard errors give a band of ±0.05643, so EvalGuard does not fail the build on noise. The margin, not just the raw delta, is what makes this gate useful.
Every serious LLM pipeline has a step like this:
- run: lm_eval --model hf --tasks arc_easy,hellaswag,gsm8k --output_path new.json
- run: <compare new.json to baseline.json>The comparison is usually a hand-rolled diff or a spreadsheet, and it fails in both directions:
- False alarms.
accmoves 0.75 → 0.74 on a 200-sample task. The harness already reportsacc_stderr = 0.03; the difference is one sample. A naiveif new < old: failblocks an unrelated refactor, and people learn to ignore the gate. - Missed regressions.
word_perplexitymoves 12.5 → 13.4 with a small stderr. Every metric lives on a different scale, so a single "allow 2%" rule is either too loose for perplexity or too tight for accuracy.
Both come from comparing raw numbers while ignoring the uncertainty the harness already computed.
For every metric of every task, EvalGuard computes movement in the metric's bad direction, compares it with a relative threshold, and checks it against a pooled standard-error noise band:
band = sigma * sqrt(se_baseline^2 + se_candidate^2)
significant = |candidate - baseline| > band
| Condition | Verdict |
|---|---|
Worsened beyond max_rel_regression, and significant |
REGRESSION → exit 1 |
Worsened beyond max_rel_regression, but inside the noise band |
PASS (noise) |
| Improved beyond the threshold | improvement (never fails) |
| Baseline is ~0 (relative change undefined) | falls back to max_abs_regression |
Zero runtime dependencies, standard library only.
$ python -m pip install evalguardFrom source:
$ git clone https://github.com/CJstate/EvalGuard
$ cd EvalGuard
$ python -m pip install -e ".[dev]"$ evalguard baseline.json candidate.jsonExit code 1 fails the CI step. A passing run — including a metric that dropped but stayed
inside its noise band — exits 0.
$ evalguard baseline.json candidate.json --format markdown --output report.md
$ evalguard baseline.json candidate.json --format json
$ evalguard baseline.json candidate.json --format github # ::error:: PR annotations
$ evalguard baseline.json candidate.json --json-output # clean JSON on stdout
$ evalguard baseline.json candidate.json --json-output guard.json--json-output PATH writes the stable evalguard/v1 JSON while preserving normal terminal or
GitHub output. --json-output without a path emits only JSON on stdout, with no ANSI codes.
--color auto|always|never controls dependency-free ANSI coloring; auto colors only a TTY and
honors NO_COLOR.
Both rows below are a 0.75 → 0.69 drop on arc_easy/acc (−8%, well past the 5% limit).
Only the reported standard error differs:
| Baseline SE | Candidate SE | Pooled band (σ=2) | Verdict |
|---|---|---|---|
| 0.02 | 0.02 | 0.057 | regression — the 0.06 drop is bigger than the band |
| 0.02 | 0.25 | 0.502 | pass — a 0.06 drop on a 0.50 band is noise |
This is covered by tests/fixtures/candidate_noisy.json and the compare tests.
CLI flags cover simple cases; TOML is easier to review and share:
[gate]
max_rel_regression = 0.05 # relative movement in the bad direction that fails
sigma = 2.0 # noise-band multiplier; higher = more forgiving
require_significance = true # never fail a change that is inside the band
fail_on_missing_metric = true
# Most specific key wins: "task.metric" > "task" > [gate] defaults.
[overrides."arc_easy.acc"]
max_rel_regression = 0.02
[overrides."gsm8k.exact_match"]
sigma = 3.0 # math benchmarks are high-variance
[overrides."wikitext.word_perplexity"]
max_rel_regression = 0.01$ evalguard baseline.json candidate.json --config gate.tomlSee gate.example.toml for every field. CLI flags override the file, so one
shared policy works locally and in CI.
Perplexity-style metrics must go down; accuracy-style metrics must go up. EvalGuard infers
this from metric names (perplexity, bits_per_byte, error_rate, and ppl / loss / bpw / wer / cer / ter are lower-is-better). Override when needed:
$ evalguard b.json c.json --lower-is-better latency_ms --higher-is-better exact_matchThis repository is itself a composite action — no build, no Node, no Docker.
name: eval regression gate
on: [pull_request]
jobs:
gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run candidate evals
run: |
lm_eval --model hf --model_args pretrained=${{ github.event.pull_request.head.sha }} \
--tasks arc_easy,hellaswag --output_path candidate.json
- id: gate
uses: CJstate/EvalGuard@v0.1.0
with:
baseline: evals/baseline.json
candidate: candidate.json
config: gate.toml
report-path: evalguard-report.md
json-output: evalguard-report.json
- name: Comment on the PR
if: always() && steps.gate.outputs.passed == 'false'
uses: marocchino/sticky-pull-request-comment@v2
with:
path: evalguard-report.md
- name: Upload machine-readable report
if: always()
run: echo '${{ steps.gate.outputs.json }}' > gate-summary.jsonThe action captures the gate's exit code and exposes:
| Output | Meaning |
|---|---|
passed |
true when the gate passed, false on regression or error |
exit-code |
0 pass, 1 regression, 2 input error |
report-path |
Markdown report path |
json |
Compact evalguard/v1 JSON document |
json-path |
JSON report path |
The gate step still exits non-zero, so downstream steps should use if: always() as above.
| Code | Meaning |
|---|---|
0 |
the gate passed |
1 |
a regression (or another configured failure) was detected |
2 |
usage or input error — unreadable/invalid results file, bad config |
- Verdicts and presentation are separate.
compare_runs()returns a plainComparisonReport; text, Markdown, JSON, and GitHub renderers are pure functions of it. Every verdict is testable without capturing stdout. - Missing data is explicit. A task absent from the candidate run, a disappeared metric, and a candidate-only task are distinct states with their own flags. Treating "absent" as "unchanged" is how regressions hide.
- Harness formats are normalized carefully.
acc,noneandacc_stderr,nonebecomeaccplus its stderr; filtered variants likeacc,strict-matchkeep their filter instead of overwriting the plain metric; non-numeric entries, booleans, andNaN/Infare dropped. - Never crash on a wall of Unicode. Windows consoles still use legacy code pages, so the CLI
degrades markers to ASCII when stdout cannot encode them (
--asciiforces it).
- The noise band is only as good as the standard errors in the results file. Sparse tasks
(
limit: 10) report optimistic stderr — pass--sigma 3or add an override. - Metrics are not independent (e.g.
accandacc_normcorrelate strongly); EvalGuard does not correct for multiple comparisons. - It compares results files only. It does not run evaluations, shard them, or normalize across harness versions.
tomllibis standard library from Python 3.11.
$ python -m pip install -e ".[dev]"
$ python -m pytest -q # 107 tests, no network, no API keys
$ bash demo/run_demo.shProve your AI runs are tamper-evident with Veritas-Harness — a zero-dependency agent harness with hash-chained, byte-level replay.