Baselines and per-row regressions
An aggregate says something changed. A baseline says which rows.
Before this, a report was an island: you knew today’s macro-F1 and nothing about
which of your two hundred rows got worse to produce it. Comparing two runs by
eye is not a code review anyone does twice.
# after a run you are happy with
php artisan eval-harness:run rag.factuality --json --out=green.json --promote-baseline
# from then on, in CI
php artisan eval-harness:run rag.factuality --json --out=ci.json \
--compare=baseline --max-regressions=0
Compared against the baseline [eval-harness/reports/green.json]
2 regressed (2 beyond this run's 5.4-point detectable difference), 1 improved, 0 added, 0 removed, 40 compared.
sample pass rate score delta beyond noise newly failing
refund-policy 100% → 40% -0.5800 yes yes
shipping-eta 80% → 40% -0.3100 yes
Gate failed: 2 rows regressed against the baseline (allowed: 0)
Exit code 1, two row names, and the receipts.
Rows join on content, not on id
The join key is a SHA-256 of the row’s input and expected output — not its
id, and not its position in the file.
| Change | Same row? |
|---|---|
Renaming sample-14 to refund-policy |
Yes — history is kept |
| Sorting the YAML file | Yes |
| Adding a tag or a cohort | Yes |
Reordering keys inside input |
Yes |
| Editing the question or the expected answer | No — old row removed, new row added |
That last line is the point: a row whose expected answer was rewritten is a
different test, and the measurements taken against the old one no longer
describe it. Reporting it as “regressed” would be a lie; reporting it as
removed-and-added is what actually happened.
The hash is the join key, never the label. Every report, table and failure
message shows the sample id, because refund-policy means something to a human
and 9f2c8a… does not.
Where the tolerance comes from
Every other tool in this space ships a constant — “ignore drops under 5%”.
That constant is wrong in both directions at once: too tight for a run of three
executions, where half the scale is sampling noise, and far too loose for a run
of three hundred, where it hides real regressions.
Here the tolerance is the run’s own detectable difference, computed from its
repetitions and pass rate (see repeated sampling).
It tightens by itself as a suite gains repetitions. Pass --compare-epsilon=0.05
when a contract needs a fixed number instead.
Status and confidence are separate
Two things are reported per row, and conflating them is how a gate loses its
audience:
- status — what happened. A drop is a drop.
- confident — whether this run had the repetitions to tell that drop apart
from the pipeline sampling differently.
A single-execution run can see that a row went from green to red, and cannot
prove it. Both facts travel:
{
"sample_id": "refund-policy",
"status": "regressed",
"confident": false,
"newly_failing": true,
"resolution": 1.0,
"pass_rate_delta": -1.0,
"score_delta": -1.0
}
By default the gate counts every regression — a row that went red is worth
stopping a pull request for, provable or not. Add --confident-only for a
scheduled lane with enough repetitions to be sure, where a false alarm costs
more than a day’s delay:
php artisan eval-harness:run rag.factuality --repetitions=10 \
--compare=baseline --confident-only --max-regressions=0
When a gate fails on rows it could not prove, it says so in the same breath:
Gate failed: 3 rows regressed against the baseline (allowed: 0);
1 of 3 exceed this run's detectable difference of 53.3 points,
the rest are within sampling noise
Managing the baseline
php artisan eval-harness:baseline rag.factuality # promote the most recent report
php artisan eval-harness:baseline rag.factuality --report=green.json # promote a specific one
php artisan eval-harness:baseline rag.factuality --show # what is it now?
php artisan eval-harness:baseline rag.factuality --clear # forget it
A baseline is a pointer, not a copy: one small JSON file naming a report
artifact that already exists on the reports disk. Nothing is duplicated, so a
baseline cannot drift from the run it claims to describe, and getting it wrong
costs one more command rather than a lost artifact.
Two refusals are deliberate:
- Promoting a report from a different dataset is refused. No row would ever
join, so every row would read as “added” and no regression could ever be
detected — a baseline that silently disables the gate is worse than none. --promote-baselinedoes not promote a run that failed. Otherwise a
regression that shipped becomes the new bar, and the next run compares
against the broken state.
Why the baseline is a file
This is the design decision that separates this package from the tools it
competes with.
When runs and baselines live only in a database, the baseline lives in one
database — whoever promoted it — and CI, which starts from an empty schema every
time, has no history at all. That gap is precisely the hole those tools then
sell a hosted service to fill.
Here the artifact and its pointer are files:
- they travel in a CI artifact,
- they can be committed next to the dataset that produced them,
- a comparison in CI reads the same bytes the developer read locally,
- and a
git diffbetween two reports is a readable document.
An optional database index can be layered on top for querying, but the file
stays the source of truth and the index is rebuildable from it.
Comparing without a baseline
--compare=latest compares against the most recent stored report for the
dataset, excluding the one this run just wrote. It is the question people ask
before they have promoted anything: is this worse than the last run I did?
--compare=<path> compares against any specific report on the reports disk.
The comparison payload
--comparison-out=diff.json writes the full row-by-row comparison:
{
"schema_version": "eval-harness.comparison.v1",
"dataset": "rag.factuality",
"reference": "the baseline [eval-harness/reports/green.json]",
"resolution": 0.054,
"resolution_is_statistical": true,
"macro_f1_delta": -0.031,
"pass_rate_delta": -0.05,
"counts": {
"regressed": 2, "regressed_confident": 2, "newly_failing": 1,
"improved": 1, "added": 0, "removed": 0, "compared": 40
},
"rows": [ … ]
}
Versioned and additive, like the report contract — wire a dashboard to it once.
A missing baseline never fails a build
If the baseline was never promoted, or the artifact it pointed at has been
deleted, the run warns and finishes with the exit code it had already earned.
Losing a reference is not a reason to fail a run that was otherwise fine, and a
CI job that goes red because somebody cleaned a storage bucket teaches everyone
to ignore it.