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.

yaml
# evals/gate.yaml
suite: chat-quality
tasks:
  - type: custom
    name: tool_calls
    tasks: evals/tool_calls.jsonl
    scorer: exact            # exact | contains | 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.60

Enable it

Either inline in soup.yaml:

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 | continue

Those 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:

bash
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:

bash
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: FAIL

The command exits 0 when every task passes and 1 when any task fails or the suite, baseline or model cannot be loaded. --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.

Baselines

  • registry://<id> — pulls eval results for the referenced registry entry
  • ./baseline.json — a JSON map of {task_name: score}
  • omitted — tasks are judged only against their threshold

Baseline provenance (v0.74.0)

bash
soup eval gate --suite ./suite.yaml --model ./output --write-baseline ./baseline.json

A 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.

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: stop for 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://localhost / http://127.0.0.1. 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 localhost and 127.0.0.1. 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.

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, so OPENAI_API_KEY was sent to it as a Bearer token (0.33.0 to 0.75.0) — from this gate, from eval.ship.judge_model, from training.online_dpo_judge and from the soup data judge 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

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.