soup loop — the production data flywheel, all from the CLI
NVIDIA's data-flywheel reference needs a multi-service stack. Tracing and observability tools capture the traffic, but turning those traces into a training set, and the training set into a gated release, is a separate job they do not do. soup loop is that job in one command, offline.
soup loop (v0.58.0) ships it as one CLI on a laptop. It connects 8 of Soup's existing uniques — v0.26 Trace-to-Preference, v0.26 Eval-Gated Training, v0.26 Registry lineage, v0.26 Quant-Lobotomy verdicts, v0.26 Soup Cans, v0.25 Autopilot, v0.54 Advise, v0.55 Eval Design, v0.56 Diagnose — into a single loop:
production traces → preference pairs → eval-gated DPO → canary deploy → auto-rollback
Seven subcommands
soup loop init <served-model> --eval <suite> --baseline registry://<id> \
--monthly-budget 50usd --max-runs-per-day 3
soup loop status
soup loop watch [--foreground|--detach] [--max-iterations N] [--poll-interval F]
soup loop pause / resume
soup loop canary <adapter> --traffic 5% [--autoroll-on-regress | --no-autoroll-on-regress]
soup loop replay [<iteration-id>] [--extract <dir>]Control plane
The full loop state lives in a single atomic file: .soup/loop.yaml (JSON-formatted via tempfile.mkstemp + os.replace, cwd-containment, direct os.lstat symlink rejection, 1 MiB cap, POSIX 0o600).
Frozen LoopState schema:
served_model/eval_suite/baseline/status(one of{running, paused, stopped})- lifetime counters
traces_collected,pairs_distilled,runs_gated,adapters_shippedanditeration_count, plusruns_todayandlast_run_datefor the daily cap (soup loop statusprints the five counters, andruns_todayasN / capwhen a daily cap is set) - canary + budget + daily-cap metadata
- iteration metadata
to_dict returns a MappingProxyType so callers can't mutate state through it. Only with_status and bumped are sanctioned mutators.
Canary router
soup loop canary <adapter> --traffic 5% records the canary and its traffic share in .soup/loop.yaml; automatic rollback on a regression is on by default (--no-autoroll-on-regress turns it off). The split itself is made by soup serve, which since v0.76.0 reads that file (see Serving the split below). The router splits traffic via deterministic SHA-256 hash routing:
- 4-byte slice mod
_HASH_MOD = 10_000→ ±0.01% split granularity - Per-request
route(policy, request_key)is pure - Verdict via
BucketStats.verdict()returnsOK/MAJOR/UNKNOWNusing v0.26 Quant-Lobotomy thresholds (5-pct regression band, min 30 samples) BucketStatsis mutable withthreading.Lockfor live request streams- Cross-field validation: canary == stable rejected,
traffic_pct ∈ [0, 100] rollbackreturns a new policy with the canary cleared (sticky-on-rollback)
Serving the split
(Since v0.76.0, #815. Through v0.75.2 soup loop canary only wrote the state file and soup serve never read it, so no request was routed to a canary.) The routing works on the transformers backend, when the canary adapter was loaded with soup serve --adapters:
soup serve --model ./base --adapters candidate=./candidate
soup loop canary candidate --traffic 5% --autoroll-on-regressStart soup serve from the directory that holds .soup/: it takes the loop state and canary-stats.json from the working directory, and no flag is needed for them.
- The key. Send a stable key as the
conversation_idfield of the request body or anX-Conversation-Idheader onPOST /v1/chat/completions. The router hashes it, so every request in one conversation stays in the same bucket. A request with no key keeps the normal active-adapter behavior, and an explicitadapterfield still wins. Clients choose their own key, so a client can deliberately pick one that hashes into the canary bucket. - The stable bucket uses the served base model, even when another adapter was activated by hand.
POST /v1/adapters/activate/<name>remains an explicit 100% cutover for unkeyed requests and does not change the canary policy. - What is counted. Completed generations and generation errors are counted per bucket in
.soup/canary-stats.json. The verdict therefore compares error rates; it does not score the quality of the output. - The verdict.
soup loop watchevaluates those counters after at least 30 canary samples. The verdict isMAJORwhen the canary's success rate trails the stable bucket's by more than five percentage points, and with--autoroll-on-regressthe watcher atomically clears the canary and its share fromloop.yaml. The serving process re-reads the policy when the state file is replaced, so the rollback applies from the next keyed request. - Buffering. Counters are flushed after 64 outcomes or two seconds, and on orderly shutdown, so the stats file and the verdict lag traffic by that window, and a crash can lose it. A canary that kills the serving process never records its own outcome: recording happens only after generation returns or raises, so such crashes are invisible to the verdict unless external monitoring sees them. Rollout ids keep one promotion's samples from being counted for the next.
- Not routed. A request with no key, a MoLE-serving run, a
soup servewith no--adaptersloaded, and any non-transformers backend are not routed by this mechanism.soup servewarns once per failure episode if the policy file cannot be read or the canary adapter is not loaded.
This section is read from the code and from upstream's documentation (utils/canary_router.py, commands/serve.py, utils/loop_daemon.py); the serve-side routing was not run here. No measurement of the split's accuracy or of rollback latency exists, so none is given.
Budget guardrails
parse_budget_string accepts "50usd" / "50" / "50 USD" (≤ $1M hard cap). Each iteration runs through check_budget → frozen BudgetDecision(proceed, reason, projected_total_usd, runs_today). Check order:
- daily-cap (UTC-day rollover via
reset_daily_counter_if_new_day) - estimate-sanity — refuse implausible cost estimates
- monthly-budget — projected total vs configured cap
Budget-skipped iterations produce no manifests — no half-records to confuse soup loop replay.
The cost estimate prices the GPU from a rate table, and the table grew in v0.76.0 (#831). L4, A10G, H200, B200, the RTX 40 and 50 series and the RTX PRO 6000 had no rate and counted as $0, so the budget gate never fired for them; they are priced now, and the gate does. Names that the old unanchored patterns mispriced (A100X, A1000, T400) now resolve to unknown instead. The table is a ballpark: laptop parts such as RTX 4090 Laptop GPU still price as the desktop card, which overstates a laptop run, and the A10G row is a whole AWS instance.
Watch daemon
soup loop watch orchestrates 5 stage callables: HarvestFn / TrainFn / GateFn / DeployFn / CostFn.
You do not have to write those callables. Since v0.71.4, --pre-wired binds real ones that compose the surfaces you already have: v0.26 trace-to-preference for the harvest, a soup train subprocess for the train, the v0.55 eval-gate for the gate, and the v0.30 /v1/adapters/activate hot-swap endpoint for the deploy. It is opt-in on both entry points, and --pack-cans additionally seals each iteration as a Soup Can.
soup loop init <served-model> --eval <suite> --baseline registry://<id> --pre-wired
soup loop watch --pre-wired --pack-cansTwo environment variables point the pre-wired stages at your own infrastructure: SOUP_LOOP_TRACE_DIR for where harvested traces live, and SOUP_LOOP_SERVE_ENDPOINT for the server to hot-swap against. Without --pre-wired the stage bindings are still no-op stubs, which is the shape the daemon shipped in at v0.58.
The deploy stage sends no bearer token, and since v0.75.1 the adapter routes can require one. It POSTs to /v1/adapters/activate/<name> with no Authorization header, and there is no environment variable to give it a token. From v0.75.1 POST /v1/adapters/activate/{name}, POST /v1/adapters/deactivate and GET /v1/adapters on soup serve check --tool-auth-token whenever one is set, and soup serve already refuses a non-loopback bind without one. So the pre-wired deploy only works against a loopback soup serve started without --tool-auth-token. Against a protected server the activation is answered 401, the iteration records activate POST failed and nothing is promoted. The endpoint itself must be a URL whose host is localhost, a loopback address, or a private or link-local IP literal, and plain http is accepted only for loopback: a private or link-local address needs https, and soup serve has no TLS option, so in practice the deploy stage reaches a loopback soup serve or one behind a TLS proxy. Any other hostname is refused rather than resolved.
run_once(state, config)is pure with respect to time (testable).watch(config)is the long-running daemon. InstallsSIGTERM/SIGINThandlers.- Reloads state every iteration so external
pause/resumetakes effect immediately without restarting the daemon. --detachspawnspython -m soup_cli.cli loop watch --foregroundvia argv-listsubprocess.Popen— no shell, no string interpolation.maybe_rollbackfires only on"MAJOR"canary verdict.
Iteration manifests
Every iteration writes a frozen IterationRecord to .soup-loops/<iteration_id>/iteration.json:
iteration_id iter-20260515T231600-a1f3b2c4
started_at 2026-05-15T23:16:00Z
finished_at 2026-05-15T23:25:12Z
pairs_harvested 89
run_id run-8f3
gate_verdict OK # OK | MAJOR | SKIPPED
canary_verdict OK # OK | MAJOR | UNKNOWN | None (None: the deploy only activated the adapter)
shipped true
rolled_back false
estimated_cost_usd 2.40
notes "" # a string; "activate POST failed" after a refused deploycanary_verdict is None when the pre-wired deploy stage only activated the adapter (v0.76.0, #815). Activation is a full hot-swap at 100% of traffic, so no canary split ran and there is no verdict to report. (Through v0.75.2 the stage recorded OK from the activation POST's bare 2xx. A manifest from an older release with canary_verdict: OK says only that the activation succeeded.) A real verdict comes from soup loop watch evaluating the counters described under Serving the split.
new_iteration_id = iter- + UTC timestamp + 8-hex uuid.uuid4() (for example iter-20260515T231600-a1f3b2c4). soup loop replay walks the directory in chronological order.
soup loop replay with no argument lists the recorded iteration ids, and with an id it prints that iteration's manifest. --extract <dir> unpacks the iteration's Soup Can (.soup-loops/<iteration_id>/iteration.can) into <dir> instead of printing the manifest. That file exists only for iterations that ran with --pack-cans; otherwise the command exits 1 with no .can for <id>. <dir> must sit under the current directory and not be a symlink (exit 2).
Known limitations
- Stage callbacks default to no-op stubs — live trace ingestion, registry baseline auto-pick, and
/v1/adapters/activaterollout are operator-driven viaWatchConfig. Since closed in v0.71.4:--pre-wiredbinds production stage callables, so the default is the only thing that is still a stub. - The pre-wired deploy stage sends no bearer token (see above), so it cannot activate an adapter on a
soup servethat was started with--tool-auth-token(v0.75.1 and later). - Canary routing needs keyed requests on the transformers backend and counts errors, not output quality (see Serving the split). Its counters lag traffic by up to 64 outcomes or two seconds.
--detachis a single-process subprocess; full daemonization (double-fork, session leader, /dev/null fds) is deferred.
See also
- Trace-to-preference — the
HarvestFnhalf of the loop - Eval-gated training — the
GateFnhalf - Registry — where
baselineand shipped runs live - Diagnose — pair with
soup train --diagnose-gatefor a second safety net - Advise — what to run before a loop iteration
- Trace ecosystem — v0.63
soup ingestfeeds the loop'sHarvestFn - Adapter lifecycle (v0.67) —
soup lockcommits a SHA256(base || dataset || env) closure so the whole team coordinates on identical reproducible loop iterations;soup adapters bisectfinds the broken iteration when the canary regresses - Pre-flight & tooling (v0.64) —
soup plan/soup applydrift-check the run plan before each loop iteration triggers a training
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.