Eval-Gated Training
v0.26.0 adds declarative eval suites that run at epoch boundaries. If a task score falls below its threshold — or regresses against a baseline — training halts before you waste another epoch.
The gate file
Every entry in tasks: is one of three types: custom, judge, or benchmark. Each task has a name (used as the baseline key) and a numeric threshold.
# evals/gate.yaml
suite: chat-quality
tasks:
- type: custom
name: tool_calls
tasks: evals/tool_calls.jsonl
scorer: exact # exact | contains | answer | regex | semantic
threshold: 0.70
- type: judge
name: chat_judge
prompts: evals/judge_prompts.jsonl
judge_model: ollama://llama3.1 # or https://... or http://localhost
threshold: 0.70 # judge scores normalise to [0,1] off the rubric scale, not raw 1-5
- type: benchmark
name: mmlu
benchmark: mini_mmlu
threshold: 0.60Enable it
Either inline in soup.yaml:
training:
eval_gate:
enabled: true
suite: ./evals/gate.yaml
every_n_epochs: 1 # 1-100
regression_threshold: 0.05 # 0.0-1.0
baseline: previous # registry://<id> | previous | a path
on_regression: stop # stop | warn | continueThose six are the whole block. baseline accepts a registry id, the literal previous, or a path to a scores JSON; on_regression decides whether a regression halts the run, prints, or is recorded and ignored.
…or on the command line:
soup train --config soup.yaml --gate ./evals/gate.yaml--gate loads the suite before training starts and exits 1 if the file is missing, outside the working directory or invalid. A training.eval_gate.suite path written in soup.yaml gets no such check up front: if the suite cannot be loaded when training begins, the callback logs eval gate: could not load suite as a warning and the gate does not run for that run, so a typo there leaves the run ungated. Once a gate is running, a gate that fails to execute mid-run does stop training under on_regression: stop.
Post-hoc verdict
Run the gate standalone against any model:
soup eval gate --suite ./evals/gate.yaml --model ./output --baseline ./baseline.json
# ✓ mmlu: 0.648 (baseline 0.643, +0.005) PASS
# ✗ chat_judge: 0.62 (baseline 0.78, -0.16) REGRESSION
# → verdict: FAILThe command exits 0 when every task passes, 2 when a task fails or regresses, 3 when the suite, the baseline or a flag is unusable (a --suite that is missing or fails the schema, a --baseline that is missing or not valid JSON, a --regression-threshold outside 0.0 to 1.0, --write-baseline without --model), and 1 when --model cannot be loaded or the suite file is not valid YAML. Before v0.76.0 every one of those was 1, so a CI step that tests for exit 1 as "the gate failed" now passes a failing gate: test for 2, and see Exit codes. The suite needs a top-level suite: name, as in the example above; without it the load is refused (exit 3). --baseline / -b takes registry://<id> or a score JSON, and --regression-threshold (0.0 to 1.0, default 0.05) sets the allowed drop. Without --model it runs a smoke test with a stub generator that answers every prompt with an empty string, so scores are meaningless.
A task fails if its score is below its threshold or if it drops more than the configured regression threshold (default 0.05) below the supplied baseline.
A task that could not be scored fails the gate closed, and the log no longer calls it a regression (v0.76.0). A task whose evaluation raises, such as an unreadable task file or a judge that stays unreachable, gets no score and counts as failed, and the training log reads N task(s) could not be scored: <name>: <error> instead of N task(s) regressed. A judge error prints the judge URL without any user-info, query string or fragment, so a base URL that carries a key does not reach the log.
Baselines
registry://<id>— pulls eval results for the referenced registry entry, using the newest eval row for each benchmark, so re-measuring a benchmark replaces its old score (before v0.76.0 the oldest row won; a gate that read aregistry://baseline measured more than once compared against the stale score)./baseline.json— a JSON map of{task_name: score}- omitted — tasks are judged only against their
threshold
Baseline provenance (v0.74.0)
soup eval gate --suite ./suite.yaml --model ./output --write-baseline ./baseline.jsonA written baseline now carries a provenance block, the Soup version and a scorer revision, alongside its scores. Reading one back warns once when the stamp is missing, which is what an older file looks like, or when the scorer revision has moved underneath it, and stays silent when it matches. That replaces the v0.73.2 warning, which recognised a stale baseline by a hardcoded list of suite names and therefore could only ever know about the one scoring change somebody remembered to add. A locked fingerprint fails the test suite if a bundled scorer's output moves without its revision being bumped, so the stamp cannot quietly stop meaning anything. The revision is 5 at v0.76.0 (it was 1 through v0.75.2): the tool-call, refusal-classifier, arithmetic and common-sense changes each bumped it, and a baseline stamped with any other revision warns once, naming the affected benchmarks when it comes from a registry entry. What changed is on the ship page.
The stamp tracks the bundled suites' scorers, and its warning does not say that your own
customtasks are affected. Two scorer changes in v0.76.0 move scores of your owncustomtasks. A baseline recorded before v0.76.0 does print the generic scorer-revision (or unknown-provenance) warning, but the message names no scorer, so it does not tell you thatcontainschanged.containsnow requires the expected string to appear as a whole token (an ASCII alphanumeric run), so14no longer contains4; a gate baseline, aregistry://row or asoup eval custom --run-idresult recorded with the old substring test can be higher than the same outputs score now, so re-measure them. (4still matches inside3.4, andcontainscannot read negation.) Andansweris a new scorer: it compares the final answer the model states. In a gate suite a task whoseexpectedstates no single answer does not stop the load: that task fails with the reason in its row, the gate fails closed andsoup eval gateexits 2.soup eval customandsoup eval quant-checkrefuse such a row when the tasks file is read. Regex-scored tasks reject structurally unsafe patterns before matching.
A judge-scored task was normalized against the wrong scale until v0.74.0. The gate divided the aggregate judge score by 10 while the default rubric runs 1 to 5, so a perfect 5.0 read as 0.50, typical thresholds of 0.70 and 0.80 were impossible to satisfy, and a healthy run could be stopped by
on_regression: stopfor no reason. Normalization now derives the minimum and maximum from the rubric in use, clamps out-of-bounds values and handles a degenerate scale. If a judge-gated run of yours stopped early, that is worth re-checking.
Judge URL allowlist
judge_model must use one of: ollama://, https://, or http:// to localhost, 127.0.0.1 or ::1 (the last since v0.76.0). Any other scheme is rejected at load time — an SSRF guard on the eval path.
Since v0.75.2 an https:// URL that names a private, link-local, shared-space (100.64.0.0/10) or otherwise non-public IP address is rejected at load too, with judge_model URL: private/link-local/reserved IP hosts are not allowed (SSRF protection). The check reads the host as an IP literal in any spelling (decimal, hex and abbreviated forms are caught, and so are non-ASCII digits); loopback still works, and plain http:// stays limited to loopback (localhost and 127.0.0.1, plus ::1 since v0.76.0). To use a judge on your own network, name it by hostname over HTTPS with a certificate valid for that name. Hostnames are not resolved, so a name that points at a private address is still accepted: the guard narrows which addresses a URL can name directly, it does not make internal services unreachable by name. There is no opt-in for the IP-literal case. See what v0.75.2 refuses.
Since v0.76.0 the refusal ends ; address the server by its hostname, and a host written only as digits that is not a valid IPv4 address (10.0.0.256, 4294967296) is refused as well, with an all-numeric host that is not a valid IPv4 address is not allowed (SSRF protection). The refusal happens when the suite loads, so soup eval gate stops with exit 3, soup train --gate stops with exit 1 (Invalid --gate suite), and a training.eval_gate suite is skipped with a warning. One limit stays: the judge's HTTP response is read whole before it is parsed and has no size cap (tracked upstream as #1819), and only the reply text is limited to 65,536 characters before it is read as scores. The endpoint is whatever the suite names, so do not run a suite you do not trust.
Which host you name decides whether your OpenAI key travels. An https:// judge URL uses OPENAI_API_KEY only when its host is api.openai.com; every other host is called as an OpenAI-compatible server without that key.
That was not true before v0.75.1, and the allowlist above is why it went unnoticed. Any
https://<host>/<model>was classified as the OpenAI provider whatever the host was, soOPENAI_API_KEYwas sent to it as a Bearer token (0.33.0 to 0.75.0) — from this gate, fromeval.ship.judge_model, fromtraining.online_dpo_judgeand from thesoup datajudge options. A shared or committed config could name a host that simply collects the key. Rotate it if you ever pointed a judge anywhere else while it was set. The flip side is a behaviour change: a self-hosted judge that was relying on the key being forwarded must now be given one explicitly.
See also
- Registry — the typical baseline source
- Evaluation — the broader eval platform
Soup is free and Apache-2.0. If it saved you a training run, starring the repo costs nothing and helps most. You can also fund the GPU time behind the work a 4 GB laptop cannot reach.