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 --config soup.yaml --emit-evidence evidence.json

# post the verdict as a PR comment (best-effort, never flips the exit code)
soup ship --config soup.yaml --push owner/repo#42
  • --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.

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, live)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).
--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. Best-effort; never flips the exit code (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.

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. Because CI resolves trl 1.x, a runtime adapter uses the pairwise judge on trl 0.19.x and the same judge pointwise on trl 1.x.
  • 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 exist but are not wired to any command, so no usage data is ever sent today.

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.