soup ship: the SHIP / DON'T-SHIP verdict (v0.71.25)

After a fine-tune there is exactly one question worth answering: did the model get better, or did I break it? soup ship answers it as a single binary verdict, SHIP or DON'T SHIP, plus a one-screen reason. It is not a dashboard to interpret, it is a decision you can gate CI on.

The trick most pipelines miss is that a model can win the task you trained for and still be worse, because it forgot how to do everything else. soup ship fuses both checks into one rule and refuses that model.

The decision rule

SHIP  ⇔  (leg 1) task_tuned > task_base          # strict improvement
     AND (leg 2) every benchmark: base − tuned ≤ forgetting_threshold
else DON'T SHIP — even if the task metric looks great.
  • Leg 1, task win — the metric you care about strictly improved from base to tuned. A tie is not a win.
  • Leg 2, no catastrophic forgetting — no general benchmark dropped more than the forgetting threshold (default 0.05 absolute points, the same semantics as the eval-gate regression threshold).

A missing baseline does not silently SHIP, it refuses with a clear message. When more than one rule fails the reason names the most decisive one (missing baseline → task win → regression).

Run it

bash
# live: score a base model and a LoRA adapter on your task + the default suite
soup ship \
  --base meta-llama/Llama-3.1-8B \
  --adapter ./output/adapter \
  --task-eval tasks.jsonl
bash
# judge the task win with an LLM, regress against named lm-eval suites
soup ship \
  --base meta-llama/Llama-3.1-8B \
  --tuned ./my-finetuned-model \
  --task-eval tasks.jsonl \
  --task-mode judge_score --judge-model ollama://llama3.1 \
  --general-suite mmlu,gsm8k \
  --baseline registry://abc123
text
DON'T SHIP
Leg 1 task win (judge_score): 0.6200 -> 0.6100  [no win]

Leg 2 general suite (threshold 5.00%)
  mmlu               0.7500 -> 0.6900   -0.0600   REGRESS
  gsm8k              0.5200 -> 0.5300   +0.0100   ok

General benchmark(s) regressed past 5.00%: mmlu.
Catastrophic forgetting: DON'T SHIP even though the task metric was ok.

Exit codes are CI-ready: 0 = SHIP, 2 = DON'T SHIP, 1 = runtime error, and (since v0.71.38) 3 = usage error (a typo'd flag or a bad --general-suite), so a misconfigured gate never masquerades as a DON'T-SHIP.

Decide offline, with no model load

--evidence ev.json reaches a verdict from pre-computed scores, so CI can gate without a GPU:

bash
soup ship --evidence ev.json --output verdict.json
json
{
  "task": { "mode": "metric", "base": 0.40, "tuned": 0.55 },
  "benchmarks": {
    "mini_mmlu":         { "base": 0.80, "tuned": 0.79 },
    "mini_common_sense": { "base": 0.60, "tuned": 0.62 },
    "mini_instruction":  { "base": 0.70, "tuned": 0.71 }
  }
}

--output verdict.json also persists the machine-readable verdict from a live run.

Pairwise judge win-rate (v0.71.31)

--task-mode pairwise decides leg 1 with a true head-to-head judge: for each prompt the judge picks base vs tuned, swap-debiased so a win only counts when both orders agree. The base is a 0.5 coin-flip, and the tuned model wins the leg only if its win-rate is above 0.5.

bash
soup ship --base <m> --adapter ./out --task-eval tasks.jsonl \
  --task-mode pairwise --judge-model ollama://llama3.1

Offline, the --evidence task block takes { "mode": "pairwise", "base": 0.5, "tuned": <winrate> }.

The regression leg grew teeth (v0.71.38)

Leg 2 is the moat, so it had to be trustworthy. It used to be fifteen trivia prompts scored by case-insensitive substring containment, which credited "B" for Berlin and "3" for 13, and had zero coverage of tool-calling, JSON validity or safety. v0.71.38 replaced that with an answer-extraction plus boundary-aware scorer and a bundled, offline general suite of seven hand-authored benchmarks shipped in the wheel:

  • mini_mmlu · mini_common_sense · mini_instruction (expanded)
  • mini_arithmetic (new)
  • mini_tool_call (function-calling), mini_format_json (JSON validity), mini_safety (refusal rate) — three the old gate could not see at all

Each suite is roughly 24 to 40 items, small enough to run offline in a blink but large enough that a single wrong answer sits well under the default 0.05 threshold and trips the gate instead of being rounded away. Everything is scored offline with Soup's own scorers: no lm-eval, no network, no download. Naming your own suites (--general-suite mmlu,gsm8k) still routes through lm-eval as an override.

An eighth suite, and two of the seven were mis-scoring (v0.73.2)

Giving the leg teeth was not the same as giving it eyes, and v0.73.2 found it ranking by the wrong thing in two suites and blind in one whole direction.

  • mini_over_refusal joins as the eighth default suite. Leg 2 flagged a drop in refusal rate and had no reverse, so a tune that refuses everything read as a monotone safety improvement. Two models with byte-identical scores on all seven shipped suites and the same verdict, one of which refuses every benign request, were indistinguishable to it. The new suite is 40 benign requests that merely sound alarming, scored as the fraction not refused, so safety and helpfulness are two axes and neither can be gamed alone.
  • mini_mmlu and mini_common_sense did not understand a boxed answer. A stub answering every item correctly scored 0.000, and Llama-3.1-8B scored 0.423, below a 0.5B, while scoring 1.000 on two other multiple-choice suites. Fixed on both halves at once (the extractor alone is worth eight items, the prompt alone worth zero): 0.423 to 0.731.
  • mini_tool_call ranked by brace hygiene. The same 8B named the right tool 40 times out of 40 and scored 0.225. Now 1.000.

A --baseline snapshot taken before v0.73.2 is on a different scale for those three suites, and the jumps on an unchanged model are larger than the 0.05 gate. Re-take it. mini_instruction and mini_arithmetic are unaffected. Full detail on the v0.73.2 page.

Measure the instrument first: --noise-floor (v0.73.2)

Greedy decoding is not deterministic on a GPU. Measured over five runs of one model with no adapter, the scores spread 0.015 strict and 0.020 format-blind, against a threshold of 0.05, and four of six paired deltas in that session sat inside the floor. Until v0.73.2 the gate compared against 0.05 without ever saying what it could resolve.

bash
soup ship --base ./base --adapter ./out --task-eval task.jsonl --noise-floor 5

--noise-floor N (N from 2 to 10) re-runs the base N times, prints the measured floor beside the verdict, and gates each axis at whichever is larger, the threshold or the floor. Two caveats travel with it: those two figures are the borrowed H100 session's, not the release's own development box, which measured 0.0000 on CPU where greedy decode is deterministic; and at n=3, one model, one dataset the floor sizes the effect rather than calibrating a threshold. Widening its scope in v0.73.3 did not change that second caveat.

Since v0.73.3 the leg-1 task floor is measured in every --task-mode, not only metric. In judge_score the base side is scored N times through the judge; in pairwise the base model is judged against itself, where the expected win rate is 0.5 by construction, so the spread is measured rather than inferred. Those repeats fold in the judge's own sampling noise, so that floor is labelled decode + judge on the panel and stamped judge_inclusive in the evidence and JSON output, and it must never be read as a decode-only number. The cost is N extra judge API calls, and a missing or malformed --judge-model is now a usage error up front rather than a failure discovered mid-measurement.

The floor is also committable. eval.ship.noise_floor joins the other gate-policy fields under eval.ship in soup.yaml, bounded to the same [2, 10] from the same constant the CLI validates against, with CLI over config over default precedence. Like the flag, it is a live-measurement input: it is measured when a run produces evidence and refused under --evidence, and it is excluded from the recipe hash, so setting a floor never invalidates evidence you already have.

Evidence you can commit (v0.71.39)

v0.71.38 made the verdict trustworthy; v0.71.39 makes it reproducible and provenance-bound, so soup ci init becomes CI for weights, not prompts.

bash
# emit the verdict back into the --evidence input schema (a run's output replays as input)
soup ship --base <m> --adapter ./out --task-eval tasks.jsonl --emit-evidence evidence.json

# commit the gate policy in soup.yaml (eval.ship), then run it; CLI > config > default
soup ship --base <m> --adapter ./out --config soup.yaml --emit-evidence evidence.json

# post the verdict as a PR comment (best-effort, never flips the exit code)
soup ship --base <m> --adapter ./out --config soup.yaml --push owner/repo#42

# offline, no model load: stamp raw scores with the config's provenance, then gate on them
soup ship --evidence scores.json --config soup.yaml --emit-evidence evidence.json
soup ship --evidence evidence.json --config soup.yaml

--config carries the gate policy only. The base and tuned models are still --base and --adapter (or --tuned), or you pass --evidence to decide offline; soup ship --config soup.yaml on its own is a usage error (exit 3). Because eval.ship.task_eval can live in the config, --task-eval becomes optional once it is committed.

  • --emit-evidence <path> re-serializes the verdict into the --evidence input schema, so the same file replays through --evidence (same --forgetting-threshold) to an identical verdict.
  • ShipConfig under eval.ship in soup.yaml + --config soup.yaml commit the gate policy (task_eval / task_mode / general_suite / forgetting_threshold / judge_model / baseline, plus noise_floor since v0.73.3). Precedence is CLI > config > default.
  • Provenance + staleness gate. Emitting evidence stamps a provenance block (an order-insensitive config_sha, the base_model, a best-effort data_sha). Reading evidence back with --config then refuses (exit 3) evidence whose config_sha drifted from the committed recipe. The gate policy itself is excluded from the hash, so tuning forgetting_threshold never falsely invalidates evidence.
  • soup ci init --config soup.yaml binds the generated PR gate to that committed config, so the check on every pull request enforces provenance too.

If you shipped a 4-bit or 8-bit adapter before v0.74.0, re-judge it

The judge loaded the base model at full precision no matter what the run had trained at, so an NF4-trained adapter was scored against a bf16 base it never saw during training, and the fallback message claimed bf16 while never actually setting a dtype. A live soup ship run with --config soup.yaml now derives the load precision from training.quantization when it is 4bit or 8bit, and prints the precision it actually loaded. Other quantization formats still fall back to full precision, and advise --probe-model, diagnose --base-model and tunability --live take no such flag yet, so they keep the old default. Two halves of that issue stay open upstream and are worth knowing before you trust a stored verdict: the numerics are not stamped into the verdict or the evidence JSON, and there is no staleness gate on evidence whose numerics do not match.

Flags

FlagDefaultDescription
--base(required, live)Base model id or path, the "before".
--tuned / --adapter(one required, live)The "after": a separate tuned model, or a LoRA adapter on top of --base. Mutually exclusive.
--task-eval(required for a live run, unless eval.ship.task_eval is set in the config)JSONL of leg-1 task-win eval items. cwd-contained, symlink-rejected.
--task-modemetricLeg-1 mode: metric (eval accuracy), judge_score (pointwise LLM-as-a-judge), or pairwise (a swap-debiased judge win-rate, base vs tuned per prompt, base = 0.5). pairwise requires --judge-model (v0.71.31).
--judge-model(judge mode only)Judge URL, validated by scheme and host (blocks SSRF prefix bypasses) and, since v0.75.2, refused if it names a private, link-local or reserved IP address (use a hostname; see private addresses refused). Checked before either model loads; a refusal is a usage error, exit 3. http accepts localhost and 127.0.0.1 only. eval.ship.judge_model goes through the same check when a judge-mode soup ship runs.
--general-suiteeight mini suitesLeg-2 benchmarks. Default is the eight bundled offline suites (seven from v0.71.38, plus mini_over_refusal in v0.73.2); named suites route through lm-eval as an override. ≤ 50 names.
--noise-floor(off)Re-run the base model N times ([2, 10]) to measure what this instrument can resolve, print it beside the verdict, and refuse to call any smaller delta significant (v0.73.2). Every --task-mode since v0.73.3; in the judge modes it costs N judge passes and is labelled decode + judge.
--baseline(none)Recorded base leg-2 scores, registry://<id> or a JSON file, to skip the base run.
--forgetting-threshold0.05Max allowed leg-2 drop in absolute points, in [0.0, 1.0].
--evidence(offline mode)Decide from a pre-computed scores JSON, no model load. O_NOFOLLOW, 16 MiB cap, cwd-contained.
--config(none)Load the gate policy from eval.ship in soup.yaml; also gates --evidence on config staleness (v0.71.39). CLI > config > default.
--emit-evidence(none)Re-serialize the verdict into the --evidence input schema, stamped with a provenance block (v0.71.39).
--push(none)owner/repo#N: post the verdict as a GitHub PR comment. Needs the gh CLI and GITHUB_TOKEN or GH_TOKEN. Best-effort; a failed post warns and never flips the exit code, but a malformed target is a usage error (exit 3) before anything runs (v0.71.39).
--output(stdout)Write the verdict JSON to a path (kept under cwd).
--deviceautocuda or cpu for the live run.

Why no other tool ships this

Leg 2, the catastrophic-forgetting gate, is the moat. Plenty of tools will tell you whether your task metric went up. What we have not found elsewhere is a first-class binary command that refuses a model on the grounds that it broke general knowledge. The regression math reuses Soup's existing eval-gate kernel, the new value is the single fused verdict, the one-screen reason, and a task-win leg that now includes a real pairwise judge win-rate.

Inference prompts now match training (v0.75.0)

Until v0.75.0 every generating command, including this one, rendered the chat template to text and then handed that text to the tokenizer, which added its own special tokens on top. On a template that renders a BOS itself — Llama-3, Gemma, Mistral — that meant a doubled BOS at inference against the single one Soup's default supervised path trains with.

Measured: 2 BOS down to 1 on vendor templates, 1 down to 0 on Soup's own data.chat_template presets, which render none, plus no trailing EOS from tokenizers that append one. usage.prompt_tokens drops accordingly.

So a verdict issued before v0.75.0 was measured on a prompt that differed from training by a token. Nothing about the weights changed, and the same applies to soup bench numbers, soup diff comparisons, soup diagnose, soup advise and the eval gate's own generators. Re-judge rather than assuming.

Two riders travel with it. An adapter trained with train_on_responses_only: false saw the doubled BOS in training, so it now differs from inference by one token in the other direction. And the vLLM, SGLang and DeepSpeed-MII engines are still affected, because they tokenize the rendered string themselves rather than receiving ids; that half is tracked upstream, and --backend transformers is the workaround if you need the verdict to match training exactly.

v1 honesty

  • Pairwise judge win-rate (--task-mode pairwise) is live as of v0.71.31: the judge picks base vs tuned per prompt, swap-debiased (a win counts only when both orders agree), base is a 0.5 coin-flip and the tuned model wins only if its win-rate exceeds 0.5. soup ship calls the judge directly and does not go through trl; the trl-capability adapter belongs to Online DPO.
  • ShipConfig under eval.ship is live as of v0.71.39 (the earlier CLI-only note no longer holds); the verdict engine is pure-Python and fully tested.
  • Built-in mini-benchmarks are offline and instant, lm-eval suites block on a real model load.
  • One honesty note that shipped alongside these releases: Soup's telemetry primitives existed with no command wired to them, so nothing was ever sent. v0.75.0 wired them, strictly opt-in and hardware-only, and the shipped wheel still sends nothing even when opted in, because its bundled project key is a placeholder the sender refuses. What the payload would carry.

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.