Gating and exit codes¶
Exit codes are the CI contract:
| Code | Meaning |
|---|---|
0 |
Gate passed |
1 |
Gate failed |
2 |
User or authoring error (bad path, malformed YAML, unknown assertion kind) |
A run fails the gate when the overall pass rate is below min_pass_rate, when a configured
per-skill minimum is not met, or when any case errored. Two distinctions matter:
- failed — the case ran and scored below the bar. An eval signal.
- errored — something in the harness blew up rather than the skill scoring badly: the runner (API error, timeout, missing key), or an evaluator (a judge endpoint returning 500, a judge verdict that does not match its rubric, an offered case on a runner that does not support the mode). An infra signal, and it fails the gate by default so CI never goes green on a broken run.
A case that fails its assertions drags the pass rate below the bar and fails the gate:
[FAIL] badskill :: expects something absent (fake)
assertion: failed: contains('NEVER_PRESENT')
0 passed, 1 failed, 0 errored — pass rate 0%
Gate FAILED:
- pass rate 0% is below the required 100%
A run that executed zero cases also fails. "Nothing ran" is a broken run, not a pass —
otherwise a mistyped path reports success forever. The reason names the cause: no skills found,
all skills skipped for having no eval cases, or every case filtered out by --tag.
Skipped (no eval cases): badskill
0 passed, 0 failed, 0 errored — pass rate 0%
Gate FAILED:
- no eval cases ran: all discovered skill(s) were skipped for having no eval cases: badskill
Every gate rule above reads the candidate arm only. Under --baseline, a run also
produces baseline outcomes, but min_pass_rate, per_skill_min, fail_on_error and the
zero-cases check never see them — a strong baseline means the skill was unnecessary, not that
CI should go red. With no baseline, candidate and baseline are the same (empty) set, so none
of these numbers move from what they were before comparative evals existed.
Gating on the delta (--min-delta)¶
--min-delta <float> adds three more gate rules, all evaluated against the
delta between the candidate and baseline arms:
- the pass-rate delta is below
min_delta; - no case was comparable — a delta gate that verified nothing must never report a pass, the same principle that fails a run executing zero cases;
- a skill's baseline could not be resolved — named, with the reason — because treating an unresolvable baseline as "no change" would let a repository pass this gate forever by deleting its git history.
--min-delta requires --baseline; passing one without the other is a user/authoring error
(exit 2), not a gate failure, since the configuration is rejected before any case runs. A
deliberately skipped baseline (an offered case under --baseline none) is not, on its own,
a gate reason — nothing went wrong there. See
Comparative evals for the full picture, including how the
delta is paired and what makes a case comparable.
Low-signal checks and high-variance cases are advisory. They are printed alongside a comparative run's output to point at weak spots in the eval suite, but they never affect the exit code — see Low-signal checks and high-variance cases.
JSON report¶
--json-output report.json writes a machine-readable report alongside the console output:
a summary block (counts, overall and per-skill pass rates, token/cost/latency totals),
skipped_skills, tag_filtered_skills, a per-case outcomes list, a top-level delta block,
baseline_notes, and the gate decision with its reasons.
Comparative evals changed this document additively, not by rewriting what was already there:
every M3 field means what it always meant, and M4 only adds fields alongside them — arm and
repeat_index on each outcome, baseline_errored in summary, and the top-level delta
(null when no baseline arm ran) and baseline_notes. A tool reading only the M3 fields
keeps working unmodified.
Each entry in outcomes carries arm ("candidate" or "baseline") and repeat_index
(0-based), so a comparative run's raw per-repetition results can be reconstructed from the
JSON even though the console collapses them to one line per case.
delta is the full comparison object — pass-rate, token, cost and latency deltas, per-case
stats, low-signal checks, high-variance cases and notes — and is null when no baseline arm
ran. baseline_notes lists why a skill's or case's baseline was skipped or unavailable.
summary.baseline_errored counts errored baseline repetitions apart from summary.errored,
which is candidate-only, for the same reason the gate itself only reads the candidate arm
(above): an errored baseline invalidates that case's delta, it does not mean the skill broke.
summary's token, cost and latency totals sum both arms — money spent is money spent —
while summary.passed / summary.failed / summary.errored / summary.pass_rate stay
candidate-only, because those are what the gate reads.
JUnit XML¶
--junit-output writes a JUnit report, the format GitHub, GitLab, Jenkins, CircleCI and
Buildkite all ingest natively.
| skill-lens | JUnit |
|---|---|
passed |
<testcase> with no child |
failed |
<testcase> with <failure> |
errored |
<testcase> with <error> |
| a skill with no cases | <testcase> with <skipped> |
The failed/errored split is the same one the exit code and the JSON report use: a
<failure> means the case ran and scored below bar, an <error> means the runner or an
evaluator blew up.
Only the candidate arm becomes test cases. Under --baseline, a failing baseline is the
evidence that the skill helped, so rendering it as a <failure> would turn CI red for the
skill working.
A run with no eval cases emits a single <testcase> carrying an <error> that repeats the
gate's reasons. An empty tests="0" file renders green in most CI UIs, which would contradict
the exit code of 1.